From 46f7d255eb77dfc87f3210c2c077ce5b152e331f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Sat, 14 Mar 2026 06:16:12 +0100 Subject: [PATCH] add iOS PagedBookView with UIPageViewController and curl transition Co-Authored-By: Claude Sonnet 4.6 --- Vorleser-iOS/PagedBookView.swift | 186 +++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 Vorleser-iOS/PagedBookView.swift diff --git a/Vorleser-iOS/PagedBookView.swift b/Vorleser-iOS/PagedBookView.swift new file mode 100644 index 0000000..b0f2167 --- /dev/null +++ b/Vorleser-iOS/PagedBookView.swift @@ -0,0 +1,186 @@ +import SwiftUI +import UIKit +import VorleserKit + +struct PagedBookView: UIViewControllerRepresentable { + let attributedText: NSAttributedString + let highlightRange: Range? + let onTapCharacter: (CharacterOffset) -> Void + var scrollToOffset: CharacterOffset? + + func makeUIViewController(context: Context) -> UIPageViewController { + let pageVC = UIPageViewController( + transitionStyle: .pageCurl, + navigationOrientation: .horizontal + ) + pageVC.dataSource = context.coordinator + pageVC.delegate = context.coordinator + + context.coordinator.parent = self + context.coordinator.recomputePages(for: pageVC.view.bounds.size) + + if let firstPage = context.coordinator.pageViewController(for: 0) { + pageVC.setViewControllers([firstPage], direction: .forward, animated: false) + } + + return pageVC + } + + func updateUIViewController(_ pageVC: UIPageViewController, context: Context) { + context.coordinator.parent = self + context.coordinator.recomputePages(for: pageVC.view.bounds.size) + + // Navigate to page containing scrollToOffset + if let offset = scrollToOffset { + let pageIndex = context.coordinator.pageIndex(containing: offset) + if let vc = context.coordinator.pageViewController(for: pageIndex) { + pageVC.setViewControllers([vc], direction: .forward, animated: false) + } + } + // Auto-follow highlight + else if let range = highlightRange { + let pageIndex = context.coordinator.pageIndex(containing: range.lowerBound) + if pageIndex != context.coordinator.currentPageIndex, + let vc = context.coordinator.pageViewController(for: pageIndex) { + let direction: UIPageViewController.NavigationDirection = pageIndex > context.coordinator.currentPageIndex ? .forward : .reverse + pageVC.setViewControllers([vc], direction: direction, animated: true) + context.coordinator.currentPageIndex = pageIndex + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { + var parent: PagedBookView + var currentPageIndex = 0 + /// Each page boundary is a character offset where the page starts. + var pageBreaks: [Int] = [0] + + init(parent: PagedBookView) { + self.parent = parent + } + + func recomputePages(for size: CGSize) { + guard parent.attributedText.length > 0 else { + pageBreaks = [0] + return + } + + let insets = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + let pageWidth = size.width - insets.left - insets.right + let pageHeight = size.height - insets.top - insets.bottom + guard pageWidth > 0, pageHeight > 0 else { + pageBreaks = [0] + return + } + + let textStorage = NSTextStorage(attributedString: parent.attributedText) + let layoutManager = NSLayoutManager() + textStorage.addLayoutManager(layoutManager) + + var breaks: [Int] = [0] + var offset = 0 + + while offset < parent.attributedText.length { + let container = NSTextContainer(size: CGSize(width: pageWidth, height: pageHeight)) + container.lineFragmentPadding = 0 + layoutManager.addTextContainer(container) + + let glyphRange = layoutManager.glyphRange(for: container) + let charRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) + + if charRange.length == 0 { break } + + offset = charRange.location + charRange.length + if offset < parent.attributedText.length { + breaks.append(offset) + } + } + + pageBreaks = breaks + } + + func pageIndex(containing characterOffset: Int) -> Int { + for (i, breakOffset) in pageBreaks.enumerated().reversed() { + if characterOffset >= breakOffset { + return i + } + } + return 0 + } + + func pageViewController(for index: Int) -> UIViewController? { + guard index >= 0, index < pageBreaks.count else { return nil } + let start = pageBreaks[index] + let end = index + 1 < pageBreaks.count ? pageBreaks[index + 1] : parent.attributedText.length + let range = NSRange(location: start, length: end - start) + + let pageText = NSMutableAttributedString(attributedString: parent.attributedText.attributedSubstring(from: range)) + + // Apply highlight if it falls within this page + if let highlightRange = parent.highlightRange { + let hlStart = max(highlightRange.lowerBound - start, 0) + let hlEnd = min(highlightRange.upperBound - start, pageText.length) + if hlStart < hlEnd { + let nsRange = NSRange(location: hlStart, length: hlEnd - hlStart) + pageText.addAttribute(.backgroundColor, value: UIColor.systemYellow.withAlphaComponent(0.3), range: nsRange) + } + } + + let vc = UIViewController() + let textView = UITextView() + textView.isEditable = false + textView.isSelectable = false + textView.isScrollEnabled = false + textView.textContainerInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + textView.attributedText = pageText + textView.tag = index + + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) + textView.addGestureRecognizer(tap) + + vc.view = textView + return vc + } + + @objc func handleTap(_ gesture: UITapGestureRecognizer) { + guard let textView = gesture.view as? UITextView else { return } + let pageIndex = textView.tag + let pageStart = pageBreaks[pageIndex] + let point = gesture.location(in: textView) + let localIndex = textView.offset( + from: textView.beginningOfDocument, + to: textView.closestPosition(to: point) ?? textView.beginningOfDocument + ) + let globalIndex = pageStart + localIndex + if globalIndex < parent.attributedText.length { + parent.onTapCharacter(globalIndex) + } + } + + // MARK: - UIPageViewControllerDataSource + + func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { + guard let textView = viewController.view as? UITextView else { return nil } + let index = textView.tag + return self.pageViewController(for: index - 1) + } + + func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { + guard let textView = viewController.view as? UITextView else { return nil } + let index = textView.tag + return self.pageViewController(for: index + 1) + } + + // MARK: - UIPageViewControllerDelegate + + func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { + guard completed, + let textView = pageViewController.viewControllers?.first?.view as? UITextView else { return } + currentPageIndex = textView.tag + } + } +}