add iOS PagedBookView with UIPageViewController and curl transition
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
186
Vorleser-iOS/PagedBookView.swift
Normal file
186
Vorleser-iOS/PagedBookView.swift
Normal file
@@ -0,0 +1,186 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import VorleserKit
|
||||
|
||||
struct PagedBookView: UIViewControllerRepresentable {
|
||||
let attributedText: NSAttributedString
|
||||
let highlightRange: Range<Int>?
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user