A SwiftUI + WKWebView EPUB reader for iOS, designed to look and feel like Apple Books. Import EPUBs, flip pages (tap / swipe / progress bar / volume buttons), and customize fonts, themes, and layouts.
Status: MVP — usable daily. Tested on: macOS 15.6 / Xcode 26.3 / iOS 18.6 device & iOS 26 simulator. See TODO.md for what's next.
iOS's built-in Books app — along with nearly every third-party reader (Kindle, Apple Books, etc.) — does not let you turn pages with the volume buttons. This has been a standard feature on Android and HarmonyOS for years, but Apple doesn't expose a public API for it.
I wanted to read in bed without reaching out of the covers to tap the screen every page. So I built SwiftBook.
| Feature | Status |
|---|---|
| EPUB import (Files app → .epub) | ✅ |
| Pagination & page-turn: tap left/right · swipe · progress bar | ✅ |
| Tap center to show/hide controls | ✅ |
| Resume reading (persists last page; progress bar syncs in library) | ✅ |
| Font size (12–40) · font family (PingFang · Source Han Serif · Georgia & more) · line spacing · alignment · themes (white · warm · dark · eye-care green) · margins | ✅ |
| Embedded images & cover rendering | ✅ |
| Volume button page turn (Vol+ = prev page, Vol- = next page; system volume unchanged) | ✅ |
| Table of contents with chapter jump (href-matched, accurate for most EPUBs) | ✅ |
| Footnote / endnote jump + back navigation (tap footnote → jump to note → "back to origin" button with auto-dismiss) | ✅ |
| Bottom bar: page jump input (enter page number → validate & jump) | ✅ |
| Reading progress tracking (today's reading time · streak · daily goal · books finished this year) | ✅ |
| Mark books as finished / unfinished (long-press context menu in library) | ✅ |
Reader/
├── README.md # Chinese README (this file is English)
├── README_EN.md # ← You are here
├── TODO.md # Progress & roadmap
├── create_project.sh # One-click .xcodeproj generator
└── SwiftBook/
├── project.yml # XcodeGen config
├── SwiftBook.xcodeproj/ # Pre-generated Xcode project
└── Sources/
├── App/SwiftBookApp.swift # App entry point
├── Models/
│ ├── Book.swift # Book model (spine, chapters, progress, cover)
│ ├── ReadingSession.swift # Reading session model (duration tracking)
│ └── ReadingSettings.swift # Reading settings (font, theme, margins… enums)
├── Views/
│ ├── LibraryView.swift # Library grid + import
│ ├── ReaderView.swift # ★ Core reader (BookWebView)
│ ├── SettingsPanelView.swift # Bottom settings panel
│ ├── ReadingStatsView.swift # Reading stats & goals tab
│ └── BookCardView.swift # Library card + progress bar
├── Services/
│ ├── BookManager.swift # Library, import, unzip, progress persistence
│ ├── ReadingStatsManager.swift # Reading stats: sessions, streaks, goals
│ ├── EPUBParser.swift # container.xml → OPF → spine / TOC
│ └── VolumeButtonHandler.swift # Volume key KVO → page turn
├── Utilities/ZipReader.swift # Minimal ZIP decompressor (stored + deflate)
└── Resources/
├── Info.plist
└── Fonts/ # Source Han Serif (Git LFS managed)
The file you'll touch most is SwiftBook/Sources/Views/ReaderView.swift — pagination, gestures, settings injection, resume reading, and image rewriting all live here (including BookWebView, a UIViewRepresentable with inline pagination JS).
git clone git@github.com:KevinLeeeee323/SwiftBookApp.git
cd SwiftBookApp
open SwiftBook/SwiftBook.xcodeproj💡 For Source Han Serif fonts: The Chinese serif font files are managed with Git LFS. A plain
git cloneonly gets pointers. To pull the actual fonts:brew install git-lfs git lfs install git lfs pull # Pulls ~75 MB of .otf files (three weights)If you don't need these two fonts, skip
git lfs pull— the app compiles and runs fine (Chinese text will use PingFang only).
The Xcode project is pre-generated — just open and run:
open SwiftBook/SwiftBook.xcodeprojIf you add/remove files under Sources/ and need to regenerate the project:
brew install xcodegen # first time only
cd SwiftBook && xcodegen generate # regenerate .xcodeproj from project.yml
# or: ./create_project.shIn Xcode: select the SwiftBook target → Signing & Capabilities → pick your development team, change the Bundle ID → choose device/simulator →
| Minimum | |
|---|---|
| iOS | 16.0 |
| macOS | 14.0 (Sonoma) |
| Xcode | 15.0 |
| Swift | 5.9 |
More granular notes live in my Claude memory (
reader-webview-architecture.md). Below is the human-readable version.
- All spine chapters are assembled into a single HTML document inside
#reader-container. Each chapter is a.content-chunkwithbreak-before: columnso chapters start on a fresh "page". - CSS multi-column layout:
column-width = viewport width,column-gap = 2 × horizontal margin. This makes one column = exactly one screen page. Page-turning iscontainer.scrollLeft = page × pageStep;measure()dividesscrollWidth / pageStepto compute total pages. - Native
scrollViewscrolling is disabled — all page navigation is driven by JSscrollLeft. - Gotcha ① — WebView dimensions must be constant:
ReaderViewuses constant.ignoresSafeArea(). I previously toggled safe area insets based onshowControls, causing the WebView to resize every time the control bar appeared/disappeared →resize → recalculatePages→ pages jumped vertically, and progress-bar navigation would jitter text. With a fixed frame, page-turning is pure horizontal displacement.applyScroll()also locksscrollTopto 0. - Gotcha ② — local images need
loadFileURL(_:allowingReadAccessTo:): Generated HTML is written toExtracted/<book>/_reader_generated.htmland loaded as a file URL for local file read permissions.loadHTMLString(baseURL:)silently blocks images.
- Settings go Swift→JS as a flat JSON dictionary (enums resolved to strings like
bgColor,fontFamilyCSS,textAlignupfront) to avoid nested encoding headaches. - Gotcha ③ (once broke ALL interactivity): The default font CSS contains single quotes (
…'San Francisco'…). If you interpolate that raw into a JS single-quoted string, the entire<script>block throws a SyntaxError →goToPage,applySettings,measureare all undefined. The body renders, but sliders, font buttons, and page-turning are all dead. Fix: all JS-side defaults are generated viaJSONSerializationinto aDEFAULTSobject (JSON handles escaping automatically); on injection, additionally escape\and'.
ReaderView.init(book:)seeds@Statevariables directly frombook.currentPage/totalPages;buildReaderHTML(initialPage:)scrolls JS to the right page on load;loadContentsetspendingPage = book.currentPage.- Gotcha ④: If
currentPageequalspendingPageinitially,updateUIViewwon't fire a spuriousgoToPage(0). That spurious 0 would be saved by.onChange(of: currentPage), wiping progress to 0% (symptom: library card progress bar stuck at 0%). Moral: never read@BindingduringloadContent— it's still 0 then.
- A transparent
Color.clearoverlay captures a singleDragGesture(minimumDistance: 0): significant horizontal drag → page turn; near-zero movement (treated as tap) → left third = prev page / right third = next page / middle = toggle controls. - Gotcha ⑤: An earlier approach with three separate tap zones + a standalone swipe gesture caused gesture conflicts over the WebView (tap vs. drag arena dispute); swipe often wouldn't register. A single combined gesture owns the interaction exclusively and is stable.
- When controls are visible, a full-screen transparent capture layer underneath the control ZStack catches center taps to dismiss (
.onTapGestureon the VStack alone misses taps onSpacerregions).
- KVO on
AVAudioSession.outputVolume;.playback + .mixWithOtherswith an active audio session; a silent WAV (volume = 0) loops in-memory to keep the session "playing". Without an active output,outputVolumeKVO often doesn't fire (especially on simulator) — button presses fall back to changing ringer volume. - A 1×1 nearly-transparent
MPVolumeViewin the key window suppresses the system volume HUD. Its internalUISliderresets volume to a 0.5 baseline after each detection, ensuring both up and down always have room to produce a delta. - Verified on real device. Not testable on simulator (simulator limitation).
- iOS ships only one Chinese font: PingFang SC. Serif and KaiTi are macOS fonts — unavailable on iOS. To offer genuine serif reading, you must bundle an open-source font.
- This project manages Source Han Serif SC (Regular + SemiBold + Bold) via Git LFS. OFL license — free for commercial use.
- Font files are ~25 MB each, ~75 MB total. Run
git lfs pullafter cloning to retrieve them.
- EPUB is essentially a ZIP file:
ZipReaderis a hand-rolled minimal decompressor (stored + deflate);EPUBParsertraversesMETA-INF/container.xml→ OPF (metadata / manifest / spine) → NCX / TOC. - All extracted paths are flattened (
/→_) intoDocuments/Extracted/<book>/. Image references in chapter HTML are rewritten viarewriteResourceRefs: try the flattened full path → fall back to filename → suffix-match against_filename.
- Volume button page-turn is not testable on simulator (simulator limitation). Works on real devices.
- EPUB only (no PDF). No bookmarks / notes, no full-text search, no TTS, no sync.
- Reading area is full-screen (ignores safe area); uses
marginV+env(safe-area-inset-*)to avoid the Dynamic Island. - Chinese fonts: currently PingFang + Source Han Serif (Song/Ming style). KaiTi (regular script) would require bundling an additional font.
See TODO.md for the roadmap.
This project was developed with AI assistance from:
- Claude (Anthropic)
- Codex (OpenAI)
- DeepSeek
MIT — see LICENSE (if present) or the repository metadata.