187 lines
6.5 KiB
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
|
|
}
|
|
}
|
|
}
|