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 } } }