Files
vorleser/Vorleser-iOS/PagedBookView.swift

187 lines
6.5 KiB
Swift

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