Books — fix infinite render loop via value-based navigation
Opening a book chapter froze the app in an infinite render loop. Root
cause: the books screens used the eager `NavigationLink { destination }`
form inside `List`/`LazyVStack`. That form keeps the destination view
structurally parented to the source row, so `BookReaderView`'s ScrollView
got trapped inside a `List` row — a row sizes to intrinsic height, a
ScrollView has none, so the two never converge and re-measure forever.
Switch the whole books navigation chain to value-based navigation:
- practiceHomeView, BookLibraryView, BookChapterListView use
NavigationLink(value:).
- PracticeView's NavigationStack declares the BooksRoute, Book, and
BookChapter destinations once, at the stack root (mixing eager and
value-based pushes in one path caused pushed screens to pop back).
- BookReaderView is built from just a BookChapter; it resolves its Book
by slug via @Query.
Also:
- BookChapter gains a stored paragraphCount so the chapter list no longer
decodes the full paragraph JSON on every render (bookDataVersion -> 6
to re-seed).
- BookSpeechController builds its AVSpeechSynthesizer lazily.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,16 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
|
|||||||
|
|
||||||
// MARK: - Internals
|
// MARK: - Internals
|
||||||
|
|
||||||
private let synthesizer = AVSpeechSynthesizer()
|
/// Built on first use, not in `init`. `AVSpeechSynthesizer()` connects to
|
||||||
|
/// the system speech daemon, so allocating one per `BookReaderView` struct
|
||||||
|
/// construction (SwiftUI rebuilds the struct on every parent render) is a
|
||||||
|
/// real cost — deferring it keeps controller construction cheap.
|
||||||
|
@ObservationIgnored
|
||||||
|
private lazy var synthesizer: AVSpeechSynthesizer = {
|
||||||
|
let synth = AVSpeechSynthesizer()
|
||||||
|
synth.delegate = self
|
||||||
|
return synth
|
||||||
|
}()
|
||||||
private var queue: [QueueEntry] = []
|
private var queue: [QueueEntry] = []
|
||||||
private var queueCursor: Int = 0
|
private var queueCursor: Int = 0
|
||||||
private var audioSessionConfigured = false
|
private var audioSessionConfigured = false
|
||||||
@@ -38,7 +47,6 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
|
|||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
synthesizer.delegate = self
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public control
|
// MARK: - Public control
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ actor DataLoader {
|
|||||||
static let textbookDataVersion = 14
|
static let textbookDataVersion = 14
|
||||||
static let textbookDataKey = "textbookDataVersion"
|
static let textbookDataKey = "textbookDataVersion"
|
||||||
|
|
||||||
static let bookDataVersion = 5 // bump: per-book glossary added
|
static let bookDataVersion = 6 // bump: BookChapter.paragraphCount added
|
||||||
static let bookDataKey = "bookDataVersion"
|
static let bookDataKey = "bookDataVersion"
|
||||||
|
|
||||||
/// Quick check: does the DB need seeding or course data refresh?
|
/// Quick check: does the DB need seeding or course data refresh?
|
||||||
@@ -632,6 +632,7 @@ actor DataLoader {
|
|||||||
bookSlug: slug,
|
bookSlug: slug,
|
||||||
number: number,
|
number: number,
|
||||||
title: chTitle,
|
title: chTitle,
|
||||||
|
paragraphCount: paragraphsES.count,
|
||||||
paragraphsESJSON: esData,
|
paragraphsESJSON: esData,
|
||||||
paragraphsENJSON: enData
|
paragraphsENJSON: enData
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,9 +19,7 @@ struct BookChapterListView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
ForEach(allChapters) { chapter in
|
ForEach(allChapters) { chapter in
|
||||||
NavigationLink {
|
NavigationLink(value: chapter) {
|
||||||
BookReaderView(chapter: chapter, book: book)
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Text("\(chapter.number)")
|
Text("\(chapter.number)")
|
||||||
.font(.subheadline.weight(.bold).monospacedDigit())
|
.font(.subheadline.weight(.bold).monospacedDigit())
|
||||||
@@ -31,7 +29,7 @@ struct BookChapterListView: View {
|
|||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(chapter.title)
|
Text(chapter.title)
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.subheadline.weight(.medium))
|
||||||
Text("\(chapter.paragraphsES().count) paragraph\(chapter.paragraphsES().count == 1 ? "" : "s")")
|
Text("\(chapter.paragraphCount) paragraph\(chapter.paragraphCount == 1 ? "" : "s")")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import SwiftUI
|
|||||||
import SharedModels
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
/// Route value for pushing the books library. Lets `PracticeView` use a
|
||||||
|
/// value-based link so the entire books navigation chain is consistent —
|
||||||
|
/// mixing a view-based push with value-based pushes deeper in the same
|
||||||
|
/// NavigationStack made pushed screens pop back immediately.
|
||||||
|
enum BooksRoute: Hashable {
|
||||||
|
case library
|
||||||
|
}
|
||||||
|
|
||||||
struct BookLibraryView: View {
|
struct BookLibraryView: View {
|
||||||
@Query(sort: \Book.title) private var books: [Book]
|
@Query(sort: \Book.title) private var books: [Book]
|
||||||
|
|
||||||
@@ -17,9 +25,7 @@ struct BookLibraryView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 12) {
|
LazyVStack(spacing: 12) {
|
||||||
ForEach(books) { book in
|
ForEach(books) { book in
|
||||||
NavigationLink {
|
NavigationLink(value: book) {
|
||||||
BookChapterListView(book: book)
|
|
||||||
} label: {
|
|
||||||
BookCard(book: book)
|
BookCard(book: book)
|
||||||
}
|
}
|
||||||
.tint(.primary)
|
.tint(.primary)
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SharedModels
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
import FoundationModels
|
import FoundationModels
|
||||||
|
|
||||||
struct BookReaderView: View {
|
struct BookReaderView: View {
|
||||||
let chapter: BookChapter
|
let chapter: BookChapter
|
||||||
let book: Book
|
|
||||||
|
/// The book this chapter belongs to, resolved by slug — used for the
|
||||||
|
/// pre-computed glossary. A @Query is safe here because the reader is
|
||||||
|
/// built lazily by `navigationDestination`: one instance, when opened.
|
||||||
|
@Query private var bookMatches: [Book]
|
||||||
|
private var book: Book? { bookMatches.first }
|
||||||
|
|
||||||
@Environment(DictionaryService.self) private var dictionary
|
@Environment(DictionaryService.self) private var dictionary
|
||||||
@State private var speech = BookSpeechController()
|
@State private var speech = BookSpeechController()
|
||||||
@@ -19,6 +25,12 @@ struct BookReaderView: View {
|
|||||||
@AppStorage("bookReaderVoiceId") private var storedVoiceId: String = ""
|
@AppStorage("bookReaderVoiceId") private var storedVoiceId: String = ""
|
||||||
@AppStorage("bookReaderRate") private var storedRate: Double = 0.45
|
@AppStorage("bookReaderRate") private var storedRate: Double = 0.45
|
||||||
|
|
||||||
|
init(chapter: BookChapter) {
|
||||||
|
self.chapter = chapter
|
||||||
|
let slug = chapter.bookSlug
|
||||||
|
_bookMatches = Query(filter: #Predicate<Book> { $0.slug == slug })
|
||||||
|
}
|
||||||
|
|
||||||
private var paragraphsES: [String] { chapter.paragraphsES() }
|
private var paragraphsES: [String] { chapter.paragraphsES() }
|
||||||
private var paragraphsEN: [String] { chapter.paragraphsEN() }
|
private var paragraphsEN: [String] { chapter.paragraphsEN() }
|
||||||
|
|
||||||
@@ -87,7 +99,7 @@ struct BookReaderView: View {
|
|||||||
speech.voiceIdentifier = storedVoiceId.isEmpty ? nil : storedVoiceId
|
speech.voiceIdentifier = storedVoiceId.isEmpty ? nil : storedVoiceId
|
||||||
speech.rate = Float(storedRate)
|
speech.rate = Float(storedRate)
|
||||||
if glossary.isEmpty {
|
if glossary.isEmpty {
|
||||||
glossary = book.glossary()
|
glossary = book?.glossary() ?? [:]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ struct PracticeView: View {
|
|||||||
practiceHomeView
|
practiceHomeView
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Book navigation is value-based and declared once here, at the
|
||||||
|
// stack root. Eager `NavigationLink { destination }` forms inside
|
||||||
|
// the List/LazyVStack of the book screens caused an infinite
|
||||||
|
// render loop; value-based links build destinations lazily.
|
||||||
|
.navigationDestination(for: BooksRoute.self) { _ in
|
||||||
|
BookLibraryView()
|
||||||
|
}
|
||||||
|
.navigationDestination(for: Book.self) { book in
|
||||||
|
BookChapterListView(book: book)
|
||||||
|
}
|
||||||
|
.navigationDestination(for: BookChapter.self) { chapter in
|
||||||
|
BookReaderView(chapter: chapter)
|
||||||
|
}
|
||||||
.navigationTitle("Practice")
|
.navigationTitle("Practice")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onAppear(perform: loadProgress)
|
.onAppear(perform: loadProgress)
|
||||||
@@ -261,9 +274,7 @@ struct PracticeView: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
// Books
|
// Books
|
||||||
NavigationLink {
|
NavigationLink(value: BooksRoute.library) {
|
||||||
BookLibraryView()
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
Image(systemName: "books.vertical.fill")
|
Image(systemName: "books.vertical.fill")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ public final class BookChapter {
|
|||||||
public var bookSlug: String = ""
|
public var bookSlug: String = ""
|
||||||
public var number: Int = 0
|
public var number: Int = 0
|
||||||
public var title: String = ""
|
public var title: String = ""
|
||||||
|
/// Spanish paragraph count, stored at seed time so chapter lists can show
|
||||||
|
/// it without decoding the full `paragraphsESJSON` blob on every render.
|
||||||
|
public var paragraphCount: Int = 0
|
||||||
public var paragraphsESJSON: Data = Data()
|
public var paragraphsESJSON: Data = Data()
|
||||||
public var paragraphsENJSON: Data = Data()
|
public var paragraphsENJSON: Data = Data()
|
||||||
|
|
||||||
@@ -18,6 +21,7 @@ public final class BookChapter {
|
|||||||
bookSlug: String,
|
bookSlug: String,
|
||||||
number: Int,
|
number: Int,
|
||||||
title: String,
|
title: String,
|
||||||
|
paragraphCount: Int,
|
||||||
paragraphsESJSON: Data,
|
paragraphsESJSON: Data,
|
||||||
paragraphsENJSON: Data
|
paragraphsENJSON: Data
|
||||||
) {
|
) {
|
||||||
@@ -25,6 +29,7 @@ public final class BookChapter {
|
|||||||
self.bookSlug = bookSlug
|
self.bookSlug = bookSlug
|
||||||
self.number = number
|
self.number = number
|
||||||
self.title = title
|
self.title = title
|
||||||
|
self.paragraphCount = paragraphCount
|
||||||
self.paragraphsESJSON = paragraphsESJSON
|
self.paragraphsESJSON = paragraphsESJSON
|
||||||
self.paragraphsENJSON = paragraphsENJSON
|
self.paragraphsENJSON = paragraphsENJSON
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user