From 85dcc7a3f236aa0f82ebbfb6cf562a1cfbf26adb Mon Sep 17 00:00:00 2001 From: Mohamed Afifi Date: Mon, 25 Dec 2023 23:24:07 -0500 Subject: [PATCH] Convert PageController to SwiftUI (#613) --- .../xcschemes/QuranPagesFeature.xcscheme | 66 ++++ .../QuranContentFeature/ContentBuilder.swift | 2 +- .../ContentViewController.swift | 203 +++++------- .../ContentViewModel.swift | 127 +++---- .../ContentImageBuilder.swift | 8 +- .../QuranPagesFeature/PageController.swift | 238 -------------- .../QuranPagesFeature/PageDataSource.swift | 167 ---------- .../QuranPagesFeature/PageSeparators.swift | 127 ------- .../QuranPagesFeature/PageViewBuilder.swift | 23 ++ .../QuranPagesFeature/PagesContainer.swift | 14 - .../QuranPaginationView.swift | 228 +++++++++++++ .../SinglePageController.swift | 75 ----- .../TwoPagesController.swift | 74 ----- .../VerticalPageController.swift | 56 ---- .../ContentTranslationBuilder.swift | 10 +- .../QuranViewFeature/QuranInteractor.swift | 35 +- Package.swift | 1 + .../Miscellaneous/ContentDimension.swift | 2 +- UI/NoorUI/Pager/PageViewController.swift | 309 ++++++++++++++++++ UI/NoorUI/Pager/QuranPageSeparators.swift | 140 ++++++++ .../Miscellaneous/DisableSafeAreaInsets.swift | 54 +++ .../StaticViewControllerRepresentable.swift | 26 ++ 22 files changed, 1019 insertions(+), 966 deletions(-) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/QuranPagesFeature.xcscheme delete mode 100644 Features/QuranPagesFeature/PageController.swift delete mode 100644 Features/QuranPagesFeature/PageDataSource.swift delete mode 100644 Features/QuranPagesFeature/PageSeparators.swift create mode 100644 Features/QuranPagesFeature/PageViewBuilder.swift delete mode 100644 Features/QuranPagesFeature/PagesContainer.swift create mode 100644 Features/QuranPagesFeature/QuranPaginationView.swift delete mode 100644 Features/QuranPagesFeature/SinglePageController.swift delete mode 100644 Features/QuranPagesFeature/TwoPagesController.swift delete mode 100644 Features/QuranPagesFeature/VerticalPageController.swift create mode 100644 UI/NoorUI/Pager/PageViewController.swift create mode 100644 UI/NoorUI/Pager/QuranPageSeparators.swift create mode 100644 UI/UIx/SwiftUI/Miscellaneous/DisableSafeAreaInsets.swift create mode 100644 UI/UIx/SwiftUI/Views/StaticViewControllerRepresentable.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/QuranPagesFeature.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/QuranPagesFeature.xcscheme new file mode 100644 index 00000000..87f9628c --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/QuranPagesFeature.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Features/QuranContentFeature/ContentBuilder.swift b/Features/QuranContentFeature/ContentBuilder.swift index 2d68cd80..052351ba 100644 --- a/Features/QuranContentFeature/ContentBuilder.swift +++ b/Features/QuranContentFeature/ContentBuilder.swift @@ -24,7 +24,7 @@ public struct ContentBuilder { // MARK: Public - public func build(withListener listener: ContentListener, input: QuranInput) -> (UIViewController, ContentViewModel) { + public func build(withListener listener: ContentListener, input: QuranInput) -> (ContentViewController, ContentViewModel) { let quran = ReadingPreferences.shared.reading.quran let noteService = container.noteService() let lastPageService = LastPageService(persistence: container.lastPagePersistence) diff --git a/Features/QuranContentFeature/ContentViewController.swift b/Features/QuranContentFeature/ContentViewController.swift index c01f25f4..6f18e87a 100644 --- a/Features/QuranContentFeature/ContentViewController.swift +++ b/Features/QuranContentFeature/ContentViewController.swift @@ -12,13 +12,14 @@ import QuranAnnotations import QuranKit import QuranPagesFeature import QuranTextKit +import SwiftUI import UIKit import UIx -final class ContentViewController: UIViewController, UIGestureRecognizerDelegate { +public final class ContentViewController: UIViewController, UIGestureRecognizerDelegate { // MARK: Lifecycle - init(viewModel: ContentViewModel) { + public init(viewModel: ContentViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } @@ -32,61 +33,47 @@ final class ContentViewController: UIViewController, UIGestureRecognizerDelegate NotificationCenter.default.removeObserver(self) } - // MARK: Internal - - var isLandscape: Bool { view.bounds.width > view.bounds.height } - var pagingStrategy: PageController.PagingStrategy = .singlePage { - didSet { - pageController?.pagingStrategy = pagingStrategy - } - } - - // MARK: - View hierarchy + // MARK: Public - override func viewDidLoad() { + override public func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .reading setUpGesture() - setUpPagingStrategyChanges() - setUpDataSourceChanges() - setUpBackgroundListener() + setUpPageCollectionBuilderChanges() setUpHighlightsListener() } - func gestureRecognizer( + public func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer ) -> Bool { true } + public func word(at point: CGPoint, in view: UIView) -> Word? { + convert(point, from: view) + .flatMap { $0.word(at: $1) } + } + + // MARK: Internal + @objc func onViewPanned(_ gesture: UIPanGestureRecognizer) { if gesture.state == .began { - viewModel.userWillBeginDragScroll() - } - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - if previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass { - updatePagingStrategy() + viewModel.listener?.userWillBeginDragScroll() } } - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - updatePagingStrategy() - } - // MARK: Private - private var pageController: PageController? + private var pagingController: UIViewController? private let viewModel: ContentViewModel private var cancellables: Set = [] private var lastHighlights: QuranHighlights? - // MARK: - Scrolling + private var pageViews: [PageView] { + findPageViews(in: self) + } private func setUpHighlightsListener() { viewModel.deps.highlightsService.$highlights @@ -107,82 +94,58 @@ final class ContentViewController: UIViewController, UIGestureRecognizerDelegate } if let ayah = highlights.verseToScrollTo(comparingTo: oldValue) { - scrollTo(page: ayah.page, animated: true, forceReload: false) - } - } - - private func scrollTo(page: Page, animated: Bool, forceReload: Bool) { - if UIApplication.shared.applicationState != .background { - // update the UI only when the app is in foreground - viewModel.dataSource?.scrollToPage(page, animated: animated, forceReload: forceReload) - viewModel.visiblePagesUpdated() - } else { - // Only update last page while in background - viewModel.updateLastPageTo([page]) + viewModel.visiblePages = [ayah.page] } } - // MARK: - Background - - private func setUpBackgroundListener() { - NotificationCenter.default.addObserver( - self, - selector: #selector(applicationDidBecomeActive), - name: UIApplication.willEnterForegroundNotification, - object: nil - ) - } - - @objc - private func applicationDidBecomeActive() { - viewModel.dataSource?.scrollToPage(viewModel.lastViewedPage, animated: false, forceReload: false) - } - - // MARK: - Page controller - - private func setUpDataSourceChanges() { - viewModel.$dataSource + private func setUpPageCollectionBuilderChanges() { + viewModel.$pageViewBuilder .receive(on: DispatchQueue.main) - .sink { [weak self] dataSource in - self?.install(dataSource) + .sink { [weak self] pageViewBuilder in + self?.install(pageViewBuilder) } .store(in: &cancellables) } - private func createPageController(navigationOrientation: UIPageViewController.NavigationOrientation) -> PageController { - if let oldPageController = pageController { - removeChild(oldPageController.viewController) + private func install(_ pageViewBuilder: PageViewBuilder?) { + guard let pageViewBuilder else { + return } - let pageController = PageController( - transitionStyle: .scroll, - navigationOrientation: navigationOrientation, - interPageSpacing: ContentDimension.interPageSpacing - ) - pageController.pagingStrategy = pagingStrategy + if let oldPagingController = pagingController { + removeChild(oldPagingController) + } - pageController.viewController.view.accessibilityIdentifier = "pages" - pageController.viewController.view.backgroundColor = UIColor.reading - pageController.viewController.view.semanticContentAttribute = .forceRightToLeft - addFullScreenChild(pageController.viewController) + let pagesView = PagesView(viewModel: viewModel, pageBuilder: pageViewBuilder.build()) + let pagingController = UIHostingController(rootView: pagesView) + self.pagingController = pagingController + addFullScreenChild(pagingController) + } - self.pageController = pageController - return pageController + private func verse(at point: CGPoint, in view: UIView) -> AyahNumber? { + convert(point, from: view) + .flatMap { $0.verse(at: $1) } } - private func install(_ dataSource: PageDataSource?) { - guard let dataSource else { - return - } + private func convert(_ point: CGPoint, from view: UIView) -> (view: PageView, point: CGPoint)? { + let localPointsAndControllers = pageViews.map { (view: $0, point: $0.view.convert(point, from: view)) } + let convertedViewPoint = localPointsAndControllers.first { $0.view.view.point(inside: $0.point, with: nil) } + return convertedViewPoint + } + + private func findPageViews(in viewController: UIViewController) -> [PageView] { + var result = [PageView]() + + for child in viewController.children { + if let fooVC = child as? PageView { + result.append(fooVC) + } - let navigationOrientation: UIPageViewController.NavigationOrientation = viewModel.verticalScrollingEnabled ? .vertical : .horizontal - let pageController = createPageController(navigationOrientation: navigationOrientation) - pageController.pagingStrategy = newPageStrategy() - dataSource.usePageViewController(pageController) + // Recursively search in the child's children + result.append(contentsOf: findPageViews(in: child)) + } - dataSource.scrollToPage(viewModel.lastViewedPage, animated: false, forceReload: true) - viewModel.visiblePagesLoaded() - highlightsUpdatedTo(viewModel.deps.highlightsService.highlights) + return result } // MARK: - Gestures @@ -207,46 +170,46 @@ final class ContentViewController: UIViewController, UIGestureRecognizerDelegate switch sender.state { case .began: - viewModel.onViewLongPressStarted(at: point, sourceView: targetView) + if let verse = verse(at: point, in: targetView) { + viewModel.onViewLongPressStarted(at: point, sourceView: targetView, verse: verse) + } case .changed: - viewModel.onViewLongPressChanged(to: point) + if let verse = verse(at: point, in: targetView) { + viewModel.onViewLongPressChanged(to: point, verse: verse) + } case .ended: viewModel.onViewLongPressEnded() default: viewModel.onViewLongPressCancelled() } } +} - // MARK: - Paging Strategy - - private func setUpPagingStrategyChanges() { - viewModel.$twoPagesEnabled.sink { [weak self] twoPagesEnabled in - self?.updatePagingStrategy(twoPagesEnabled) +private struct PagesView: View { + @ObservedObject var viewModel: ContentViewModel + let pageBuilder: (Page) -> UIViewController + + var body: some View { + GeometryReader { geometry in + QuranPaginationView( + pagingStrategy: pagingStrategy(with: geometry), + selection: $viewModel.visiblePages, + pages: viewModel.pages + ) { page in + StaticViewControllerRepresentable(viewController: pageBuilder(page)) + } } - .store(in: &cancellables) - } - - private func updatePagingStrategy() { - pageController?.pagingStrategy = newPageStrategy() } - private func updatePagingStrategy(_ twoPagesEnabled: Bool) { - pageController?.pagingStrategy = newPageStrategy(twoPagesEnabled) - } - - private func newPageStrategy() -> PageController.PagingStrategy { - newPageStrategy(viewModel.twoPagesEnabled) - } - - private func newPageStrategy(_ twoPagesEnabled: Bool) -> PageController.PagingStrategy { - let enoughHorizontalSpace = TwoPagesUtils.hasEnoughHorizontalSpace() - let verticalScrolling = viewModel.verticalScrollingEnabled + func pagingStrategy(with geometry: GeometryProxy) -> PagingStrategy { + if geometry.size.height > geometry.size.width { + return .singlePage + } - let shouldDisplayTwoPages = !verticalScrolling - && isLandscape - && enoughHorizontalSpace - && twoPagesEnabled + if !TwoPagesUtils.hasEnoughHorizontalSpace() { + return .singlePage + } - return shouldDisplayTwoPages ? .twoPages : .singlePage + return viewModel.pagingStrategy } } diff --git a/Features/QuranContentFeature/ContentViewModel.swift b/Features/QuranContentFeature/ContentViewModel.swift index 43b7688e..a92b0590 100644 --- a/Features/QuranContentFeature/ContentViewModel.swift +++ b/Features/QuranContentFeature/ContentViewModel.swift @@ -22,13 +22,12 @@ import VLogging @MainActor public protocol ContentListener: AnyObject { - func setVisiblePages(_ pages: [Page]) func userWillBeginDragScroll() func presentAyahMenu(in sourceView: UIView, at point: CGPoint, verses: [AyahNumber]) } @MainActor -public final class ContentViewModel { +public final class ContentViewModel: ObservableObject { struct Deps { let analytics: AnalyticsLibrary let quranContentStatePreferences = QuranContentStatePreferences.shared @@ -40,8 +39,8 @@ public final class ContentViewModel { let highlightsService: QuranHighlightsService - let imageDataSourceBuilder: PageDataSourceBuilder - let translationDataSourceBuilder: PageDataSourceBuilder + let imageDataSourceBuilder: PageViewBuilder + let translationDataSourceBuilder: PageViewBuilder } private struct LongPressData { @@ -58,6 +57,7 @@ public final class ContentViewModel { self.deps = deps self.input = input + visiblePages = [input.initialPage] pages = deps.quran.pages twoPagesEnabled = deps.quranContentStatePreferences.twoPagesEnabled @@ -70,10 +70,10 @@ public final class ContentViewModel { .sink { [weak self] in self?.verticalScrollingEnabled = $0 } .store(in: &cancellables) deps.quranContentStatePreferences.$quranMode - .sink { [weak self] _ in self?.loadNewElementModule() } + .sink { [weak self] _ in self?.reloadAllPages() } .store(in: &cancellables) deps.selectedTranslationsPreferences.$selectedTranslations - .sink { [weak self] _ in self?.loadNewElementModule() } + .sink { [weak self] _ in self?.reloadAllPages() } .store(in: &cancellables) loadNotes() @@ -82,7 +82,11 @@ public final class ContentViewModel { // MARK: Public - public var visiblePages: [Page] { dataSource?.visiblePages ?? [] } + @Published public var visiblePages: [Page] { + didSet { + visiblePagesUpdated() + } + } public func removeAyahMenuHighlight() { longPressData = nil @@ -97,68 +101,30 @@ public final class ContentViewModel { deps.highlightsService.highlights.pointedWord = word } - public func word(at point: CGPoint, in view: UIView) -> Word? { - dataSource?.word(at: point, in: view) - } - public func highlightReadingAyah(_ ayah: AyahNumber?) { deps.highlightsService.highlights.readingVerses = [ayah].compactMap { $0 } } // MARK: Internal + let deps: Deps weak var listener: ContentListener? @Published var twoPagesEnabled: Bool - @Published var dataSource: PageDataSource? - - let deps: Deps - - var verticalScrollingEnabled: Bool { - didSet { loadNewElementModule() } - } - - var lastViewedPage: Page { - deps.lastPageUpdater.lastPage ?? input.initialPage - } - - func userWillBeginDragScroll() { - listener?.userWillBeginDragScroll() - } - - func visiblePagesLoaded() { - let pages = visiblePages - let isTranslationView = deps.quranContentStatePreferences.quranMode == .translation - crasher.setValue(pages.map(\.pageNumber), forKey: .pages) - deps.analytics.showing( - pages: pages, - isTranslation: isTranslationView, - numberOfSelectedTranslations: deps.selectedTranslationsPreferences.selectedTranslations.count, - arabicFontSize: deps.fontSizePreferences.arabicFontSize, - translationFontSize: deps.fontSizePreferences.translationFontSize - ) - if isTranslationView { - logger.info("Using translations \(deps.selectedTranslationsPreferences.selectedTranslations)") - } + @Published var pageViewBuilder: PageViewBuilder? - listener?.setVisiblePages(pages) - updateLastPageTo(pages) - } + let pages: [Page] - func visiblePagesUpdated() { - // remove search highlight when page changes - deps.highlightsService.highlights.searchVerses = [] - visiblePagesLoaded() + var pagingStrategy: PagingStrategy { + let shouldDisplayTwoPages = !verticalScrollingEnabled && twoPagesEnabled + return shouldDisplayTwoPages ? .doublePage : .singlePage } - func updateLastPageTo(_ pages: [Page]) { - deps.lastPageUpdater.updateTo(pages: pages) + var verticalScrollingEnabled: Bool { + didSet { reloadAllPages() } } - func onViewLongPressStarted(at point: CGPoint, sourceView: UIView) { - guard let verse = dataSource?.verse(at: point, in: sourceView) else { - return - } + func onViewLongPressStarted(at point: CGPoint, sourceView: UIView, verse: AyahNumber) { longPressData = LongPressData( sourceView: sourceView, startPosition: point, @@ -168,13 +134,10 @@ public final class ContentViewModel { ) } - func onViewLongPressChanged(to point: CGPoint) { + func onViewLongPressChanged(to point: CGPoint, verse: AyahNumber) { guard var longPressData else { return } - guard let verse = dataSource?.verse(at: point, in: longPressData.sourceView) else { - return - } longPressData.endVerse = verse self.longPressData = longPressData } @@ -199,7 +162,6 @@ public final class ContentViewModel { private var cancellables: Set = [] private let input: QuranInput - private var pages: [Page] private var longPressData: LongPressData? { didSet { @@ -219,9 +181,7 @@ public final class ContentViewModel { return start.array(to: end) } - // MARK: - Element - - private var dataSourceBuilder: PageDataSourceBuilder { + private var newPageCollectionBuilder: PageViewBuilder { switch deps.quranContentStatePreferences.quranMode { case .arabic: return deps.imageDataSourceBuilder case .translation: return deps.translationDataSourceBuilder @@ -238,24 +198,45 @@ public final class ContentViewModel { private func configureAsInitialPage() { deps.lastPageUpdater.configure(initialPage: input.initialPage, lastPage: input.lastPage) - loadNewElementModule() + reloadAllPages() deps.highlightsService.highlights.searchVerses = [input.highlightingSearchAyah].compactMap { $0 } } - private func loadNewElementModule() { - let dataSource = dataSourceBuilder.build( - actions: .init( - visiblePagesUpdated: { [weak self] in await self?.visiblePagesUpdated() } - ), - pages: pages + private func visiblePagesUpdated() { + // remove search highlight when page changes + deps.highlightsService.highlights.searchVerses = [] + + let pages = visiblePages + let isTranslationView = deps.quranContentStatePreferences.quranMode == .translation + crasher.setValue(pages.map(\.pageNumber), forKey: .pages) + deps.analytics.showing( + pages: pages, + isTranslation: isTranslationView, + numberOfSelectedTranslations: deps.selectedTranslationsPreferences.selectedTranslations.count, + arabicFontSize: deps.fontSizePreferences.arabicFontSize, + translationFontSize: deps.fontSizePreferences.translationFontSize ) - dataSource.items = pages - self.dataSource = dataSource + if isTranslationView { + logger.info("Using translations \(deps.selectedTranslationsPreferences.selectedTranslations)") + } + + updateLastPageTo(pages) + } + + private func updateLastPageTo(_ pages: [Page]) { + deps.lastPageUpdater.updateTo(pages: pages) + } + + private func reloadAllPages() { + switch deps.quranContentStatePreferences.quranMode { + case .arabic: + pageViewBuilder = deps.imageDataSourceBuilder + case .translation: + pageViewBuilder = deps.translationDataSourceBuilder + } } - @objc private func loadNotes() { - // set highlighted notes verses deps.noteService.notes(quran: deps.quran) .map { notes in notes.flatMap { note in note.verses.map { ($0, note) } } } .receive(on: DispatchQueue.main) diff --git a/Features/QuranImageFeature/ContentImageBuilder.swift b/Features/QuranImageFeature/ContentImageBuilder.swift index 4e3b35b5..d5a4368b 100644 --- a/Features/QuranImageFeature/ContentImageBuilder.swift +++ b/Features/QuranImageFeature/ContentImageBuilder.swift @@ -21,7 +21,7 @@ import Utilities import VLogging @MainActor -public struct ContentImageBuilder: PageDataSourceBuilder { +public struct ContentImageBuilder: PageViewBuilder { // MARK: Lifecycle public init(container: AppDependencies, highlightsService: QuranHighlightsService) { @@ -31,7 +31,7 @@ public struct ContentImageBuilder: PageDataSourceBuilder { // MARK: Public - public func build(actions: PageDataSourceActions, pages: [Page]) -> PageDataSource { + public func build() -> (Page) -> PageView { let reading = ReadingPreferences.shared.reading let readingDirectory = readingDirectory(reading) @@ -41,9 +41,11 @@ public struct ContentImageBuilder: PageDataSourceBuilder { cropInsets: reading.cropInsets ) + let pages = reading.quran.pages let cacheableImageService = createCahceableImageService(imageService: imageService, pages: pages) let cacheablePageMarkers = createPageMarkersService(imageService: imageService, reading: reading, pages: pages) - return PageDataSource(actions: actions) { page in + + return { page in let controller = ContentImageViewController( page: page, dataService: cacheableImageService, diff --git a/Features/QuranPagesFeature/PageController.swift b/Features/QuranPagesFeature/PageController.swift deleted file mode 100644 index 06e58b1d..00000000 --- a/Features/QuranPagesFeature/PageController.swift +++ /dev/null @@ -1,238 +0,0 @@ -// -// PageController.swift -// Quran -// -// Created by Mohamed Afifi on 2022-09-04. -// Copyright © 2022 Quran.com. All rights reserved. -// - -import UIKit -import VLogging -import WeakSet - -@MainActor -public class PageController { - struct Actions { - let viewControllerAtIndex: @MainActor (Int) -> UIViewController - let indexOfViewController: @MainActor (UIViewController) -> Int? - let transitionCompleted: @MainActor () -> Void - let singlePageContainer: @MainActor (UIViewController, Bool) -> PagesContainer - let twoPagesContainer: @MainActor (UIViewController, UIViewController) -> PagesContainer - } - - // MARK: Lifecycle - - public init( - transitionStyle: UIPageViewController.TransitionStyle, - navigationOrientation: UIPageViewController.NavigationOrientation, - interPageSpacing: CGFloat - ) { - self.navigationOrientation = navigationOrientation - underlying = UIPageViewController( - transitionStyle: transitionStyle, - navigationOrientation: navigationOrientation, - options: [.interPageSpacing: interPageSpacing] - ) - handler = PageControllerDelegateHandler(controller: self) - - underlying.delegate = handler - underlying.dataSource = handler - for subview in underlying.view.subviews { - if let scrollView = subview as? UIScrollView { - scrollView.delegate = handler - } - } - } - - // MARK: Public - - public enum PagingStrategy { - case singlePage - case twoPages - } - - public var pagingStrategy: PagingStrategy = .singlePage { - didSet { - if oldValue == pagingStrategy { - return - } - clearLoadedViewsCache() - if let firstVisibleIndex = visibleIndices.first { - scrollToPageAtIndex(firstVisibleIndex, animated: false, userInitiated: true) - } - } - } - - public var viewController: UIViewController { underlying } - - // MARK: Internal - - var navigationOrientation: UIPageViewController.NavigationOrientation - - var actions: Actions? - var numberOfPages: Int = 0 - - var visibleControllers: [UIViewController] { usersViewControllers(underlying.viewControllers ?? []) } - var visibleIndices: [Int] { visibleControllers.compactMap { actions?.indexOfViewController($0) } } - var loadedViews: [UIViewController] { usersViewControllers(handler.map { Array($0.loadedViews) } ?? []) } - - func clearLoadedViewsCache() { - handler?.loadedViews.removeAllObjects() - } - - func scrollToPageAtIndex(_ scrollIndex: Int, animated: Bool, userInitiated: Bool) { - let forward = visibleIndices.allSatisfy { $0 > scrollIndex } - setViewControllers( - [viewControllerAtIndex(scrollIndex)], - direction: forward ? .forward : .reverse, - animated: animated, - userInitiated: userInitiated - ) - } - - func viewControllerAfter(_ viewController: UIViewController) -> UIViewController? { - guard let userViewController = usersViewControllers([viewController]).last else { - return nil - } - guard let previousIndex = actions?.indexOfViewController(userViewController) else { - return nil - } - let newIndex = previousIndex + 1 - if newIndex >= numberOfPages { - return nil - } - return viewControllerAtIndex(newIndex) - } - - func viewControllerBefore(_ viewController: UIViewController) -> UIViewController? { - guard let userViewController = usersViewControllers([viewController]).first else { - return nil - } - guard let previousIndex = actions?.indexOfViewController(userViewController) else { - return nil - } - let newIndex = previousIndex - 1 - if newIndex < 0 { - return nil - } - return viewControllerAtIndex(newIndex) - } - - // MARK: Private - - private let underlying: UIPageViewController - private var handler: PageControllerDelegateHandler? - - private func setViewControllers( - _ viewControllers: [UIViewController], - direction: UIPageViewController.NavigationDirection, - animated: Bool, - userInitiated: Bool - ) { - if !userInitiated && (handler?.userDraggingStartedTransitionInProgress ?? false) { - logger.info("Cannot change page while user dragging in progress") - - // when user dragging initiated transition is still in progress, - // prevent the app from starting simultaneous transitions to avoid assertion failure and crash - // reference: https://github.com/hons82/THSegmentedPager/blob/master/THSegmentedPager/THSegmentedPager.m#L233 - - // failure type 1: Assertion failure in - // -[UIPageViewController queuingScrollView:didEndManualScroll:toRevealView:direction:animated:didFinish:didComplete:], - // /SourceCache/UIKit_Sim/UIKit-2935.137/UIPageViewController.m:1866 - // Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'No view controller managing visible view - - // failure type 2: Assertion failure in -[_UIQueuingScrollView _enqueueCompletionState:], - // /SourceCache/UIKit_Sim/UIKit-2935.137/_UIQueuingScrollView.m:499 - // Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Duplicate states in queue' - return - } - - viewControllers.forEach { handler?.loadedViews.insert($0) } - underlying.setViewControllers(viewControllers, direction: direction, animated: animated) - } - - private func viewControllerAtIndex(_ index: Int) -> PagesContainer { - guard let actions else { - fatalError("viewControllerAtIndex is called with no configured actions") - } - switch pagingStrategy { - case .singlePage: - return actions.singlePageContainer(actions.viewControllerAtIndex(index), index % 2 == 0) - case .twoPages: - let otherIndex = index % 2 == 0 ? index + 1 : index - 1 - let indices = [index, otherIndex].sorted() - return actions.twoPagesContainer( - actions.viewControllerAtIndex(indices[0]), - actions.viewControllerAtIndex(indices[1]) - ) - } - } - - private func usersViewControllers(_ viewControllers: [UIViewController]) -> [UIViewController] { - viewControllers.flatMap { viewController -> [UIViewController] in - if let container = viewController as? PagesContainer { - return container.pages - } else { - fatalError("ViewController \(viewController) is not a PagesContainer") - } - } - } -} - -private class PageControllerDelegateHandler: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate, UIScrollViewDelegate { - // MARK: Lifecycle - - init(controller: PageController) { - self.controller = controller - } - - // MARK: Internal - - var userDraggingStartedTransitionInProgress = false - - // we need to keep track of loaded views - // because page controller could caches views - // but doesn't expose them and we need them for reloading - let loadedViews = UnsafeWeakSet() - - func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { - let viewController = controller?.viewControllerBefore(viewController) - if let viewController { - loadedViews.insert(viewController) - } - return viewController - } - - func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { - let viewController = controller?.viewControllerAfter(viewController) - if let viewController { - loadedViews.insert(viewController) - } - return viewController - } - - func pageViewController( - _ pageViewController: UIPageViewController, - didFinishAnimating finished: Bool, - previousViewControllers: [UIViewController], - transitionCompleted completed: Bool - ) { - if completed { - controller?.actions?.transitionCompleted() - } - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - if scrollView.isTracking || scrollView.isDecelerating { - userDraggingStartedTransitionInProgress = true - } - } - - func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - userDraggingStartedTransitionInProgress = false - } - - // MARK: Private - - private weak var controller: PageController? -} diff --git a/Features/QuranPagesFeature/PageDataSource.swift b/Features/QuranPagesFeature/PageDataSource.swift deleted file mode 100644 index b41df6f1..00000000 --- a/Features/QuranPagesFeature/PageDataSource.swift +++ /dev/null @@ -1,167 +0,0 @@ -// -// PageDataSource.swift -// Quran -// -// Created by Afifi, Mohamed on 9/13/19. -// Copyright © 2019 Quran.com. All rights reserved. -// - -import QuranKit -import UIKit -import VLogging - -@MainActor -public protocol PageDataSourceBuilder { - func build(actions: PageDataSourceActions, pages: [Page]) -> PageDataSource -} - -@MainActor -public protocol PageView: UIViewController { - var page: Page { get } - - func word(at point: CGPoint) -> Word? - func verse(at point: CGPoint) -> AyahNumber? -} - -@MainActor -public struct PageDataSourceActions { - // MARK: Lifecycle - - public init(visiblePagesUpdated: @Sendable @escaping () async -> Void) { - self.visiblePagesUpdated = visiblePagesUpdated - } - - // MARK: Public - - public let visiblePagesUpdated: @Sendable () async -> Void -} - -@MainActor -public class PageDataSource { - // MARK: Lifecycle - - public init(actions: PageDataSourceActions, viewBuilder: @escaping (Page) -> PageView) { - self.actions = actions - self.viewBuilder = viewBuilder - } - - // MARK: Public - - public var items: [Page] = [] { - didSet { - updatePageControllerData() - } - } - - public var visiblePages: [Page] { pageController?.visibleIndices.map { items[$0] } ?? [] } - - public func usePageViewController(_ pageController: PageController) { - self.pageController = pageController - pageController.actions = PageController.Actions( - viewControllerAtIndex: { [weak self] in self?.pageView(at: $0) ?? UIViewController() }, - indexOfViewController: { [weak self] in self?.indexOfViewController($0) }, - transitionCompleted: { [weak self] in - logger.info("User manually scrolled UIPageViewController") - guard let self else { return } - Task { - await self.actions.visiblePagesUpdated() - } - }, - singlePageContainer: { [weak self] in - guard let self else { - fatalError("singlePageContainer called with a nil self.") - } - return singlePageController(controller: $0, isLeftSide: $1) - }, - twoPagesContainer: { TwoPagesController(first: $0, second: $1) } - ) - } - - public func scrollToPage(_ page: Page, animated: Bool, forceReload: Bool) { - // Don't scroll if page is visible - if !forceReload, visiblePages.contains(page) { - return - } - guard let scrollIndex = indexOfPage(page) else { - logger.debug("scrolling to page \(page) not part of loaded pages") - return - } - pageController?.scrollToPageAtIndex(scrollIndex, animated: animated, userInitiated: forceReload) - } - - // MARK: - Word & Verse position - - public func word(at point: CGPoint, in view: UIView) -> Word? { - convert(point, from: view) - .flatMap { $0.word(at: $1) } - } - - public func verse(at point: CGPoint, in view: UIView) -> AyahNumber? { - convert(point, from: view) - .flatMap { $0.verse(at: $1) } - } - - // MARK: Private - - private let viewBuilder: (Page) -> PageView - private let actions: PageDataSourceActions - - private var pageController: PageController? { - didSet { - updatePageControllerData() - } - } - - private var loadedViews: [PageView] { - pageController?.loadedViews.compactMap { $0 as? PageView } ?? [] - } - - // MARK: - Page Controller - - private func updatePageControllerData() { - pageController?.clearLoadedViewsCache() - pageController?.numberOfPages = items.count - } - - private func singlePageController(controller: UIViewController, isLeftSide: Bool) -> PagesContainer { - guard let pageController else { - fatalError("pageController is nil") - } - if pageController.navigationOrientation == .horizontal { - return SinglePageController(controller: controller, isLeftSide: isLeftSide) - } else { - return VerticalPageController(controller: controller) - } - } - - // MARK: - Creating View - - private func indexOfViewController(_ viewController: UIViewController) -> Int? { - guard let view = viewController as? PageView else { - return nil - } - return indexOfPage(view.page) - } - - private func pageView(at index: Int) -> PageView { - let page = items[index] - if let existing = loadedViews.first(where: { $0.page == page }) { - return existing - } else { - let view = viewBuilder(page) - view.view.backgroundColor = nil - return view - } - } - - private func indexOfPage(_ page: Page) -> Int? { - items.firstIndex(of: page) - } - - private func convert(_ point: CGPoint, from view: UIView) -> (view: PageView, point: CGPoint)? { - let controllers = pageController?.visibleControllers as? [PageView] ?? [] - let localPointsAndControllers = controllers.lazy.map { (view: $0, point: $0.view.convert(point, from: view)) } - let convertedViewPoint = localPointsAndControllers.first { $0.view.view.point(inside: $0.point, with: nil) } - return convertedViewPoint - } -} diff --git a/Features/QuranPagesFeature/PageSeparators.swift b/Features/QuranPagesFeature/PageSeparators.swift deleted file mode 100644 index 2d5aecd8..00000000 --- a/Features/QuranPagesFeature/PageSeparators.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// PageSeparators.swift -// Quran -// -// Created by Mohamed Afifi on 2022-10-07. -// Copyright © 2022 Quran.com. All rights reserved. -// - -import UIKit -import UIx - -class MiddlePageSeparator: UIView { - // MARK: Lifecycle - - override init(frame: CGRect) { - super.init(frame: frame) - setUp() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Private - - private func setUp() { - let leftSide = MiddleSidePageSeparator(isLeftSide: true) - let rightSide = MiddleSidePageSeparator(isLeftSide: false) - for child in [leftSide, rightSide] { - addAutoLayoutSubview(child) - child.vc.verticalEdges() - } - leftSide.vc.leading() - rightSide.vc.trailing() - addSiblingHorizontalContiguous(left: leftSide, right: rightSide) - } -} - -class MiddleSidePageSeparator: UIView { - // MARK: Lifecycle - - init(isLeftSide: Bool) { - super.init(frame: .zero) - setUp(isLeftSide: isLeftSide) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Internal - - static let width: CGFloat = 7 - static let lineWidth: CGFloat = 0.5 - - // MARK: Private - - private static let leftSideColors = [UIColor.reading, UIColor.pageSeparatorBackground] - - private let gradientView = GradientView(type: .axial) - - private func setUp(isLeftSide: Bool) { - let colors = isLeftSide ? Self.leftSideColors : Self.leftSideColors.reversed() - gradientView.colors = colors - - addAutoLayoutSubview(gradientView) - gradientView.vc.edges() - gradientView.vc.width(by: Self.width) - - if isLeftSide { - let line = UIView() - line.backgroundColor = UIColor.pageSeparatorLine - addAutoLayoutSubview(line) - line.vc.verticalEdges().width(by: Self.lineWidth).trailing() - } - } -} - -class SidePageSeparator: UIView { - // MARK: Lifecycle - - private init(colors: [UIColor]) { - super.init(frame: .zero) - setUp(colors: colors) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Internal - - static let width: CGFloat = 10 - - static func leftSide() -> SidePageSeparator { - SidePageSeparator(colors: leftSideColors) - } - - static func rightSide() -> SidePageSeparator { - SidePageSeparator(colors: leftSideColors.reversed()) - } - - // MARK: Private - - private static let leftSideColors = [UIColor.pageSeparatorBackground, UIColor.reading] - - private func setUp(colors: [UIColor]) { - let gradientView = GradientView(type: .axial) - gradientView.semanticContentAttribute = .forceLeftToRight - gradientView.colors = colors - addAutoLayoutSubview(gradientView) - gradientView.vc.edges().width(by: Self.width) - - let lines = 5 - for i in 0 ..< lines { - let step = Self.width / (CGFloat(lines) - 1) - let distance = CGFloat(i) * step - let line = UIView() - line.backgroundColor = UIColor.pageSeparatorLine - addAutoLayoutSubview(line) - line.vc.verticalEdges().width(by: 0.5).leading(by: CGFloat(distance)) - } - } -} diff --git a/Features/QuranPagesFeature/PageViewBuilder.swift b/Features/QuranPagesFeature/PageViewBuilder.swift new file mode 100644 index 00000000..9c9d46b2 --- /dev/null +++ b/Features/QuranPagesFeature/PageViewBuilder.swift @@ -0,0 +1,23 @@ +// +// PageViewBuilder.swift +// Quran +// +// Created by Afifi, Mohamed on 9/13/19. +// Copyright © 2019 Quran.com. All rights reserved. +// + +import QuranKit +import UIKit + +@MainActor +public protocol PageViewBuilder { + func build() -> (Page) -> PageView +} + +@MainActor +public protocol PageView: UIViewController { + var page: Page { get } + + func word(at point: CGPoint) -> Word? + func verse(at point: CGPoint) -> AyahNumber? +} diff --git a/Features/QuranPagesFeature/PagesContainer.swift b/Features/QuranPagesFeature/PagesContainer.swift deleted file mode 100644 index d350daaf..00000000 --- a/Features/QuranPagesFeature/PagesContainer.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// PagesContainer.swift -// Quran -// -// Created by Mohamed Afifi on 2022-10-08. -// Copyright © 2022 Quran.com. All rights reserved. -// - -import UIKit - -@MainActor -protocol PagesContainer: UIViewController { - var pages: [UIViewController] { get } -} diff --git a/Features/QuranPagesFeature/QuranPaginationView.swift b/Features/QuranPagesFeature/QuranPaginationView.swift new file mode 100644 index 00000000..11ac8a52 --- /dev/null +++ b/Features/QuranPagesFeature/QuranPaginationView.swift @@ -0,0 +1,228 @@ +// +// QuranPaginationView.swift +// +// +// Created by Mohamed Afifi on 2023-12-25. +// + +import NoorUI +import QuranKit +import SwiftUI + +public enum PagingStrategy { + case singlePage + case doublePage +} + +public struct QuranPaginationView: View { + // MARK: Lifecycle + + public init(pagingStrategy: PagingStrategy, selection: Binding<[Page]>, pages: [Page], content: @escaping (Page) -> Content) { + self.pagingStrategy = pagingStrategy + _selection = selection + self.pages = pages + self.content = content + } + + // MARK: Public + + public var body: some View { + Group { + switch pagingStrategy { + case .singlePage: + QuranSinglePaginationView( + selection: singlePageSelection, + pages: pages, + content: contentView + ) + case .doublePage: + QuranDoublePaginationView( + selection: $selection, + pages: pages, + content: contentView + ) + } + } + .environment(\.layoutDirection, .rightToLeft) + .accessibilityIdentifier("pages") + .background(Color(.reading)) + .ignoresSafeArea() + } + + // MARK: Private + + @Environment(\.layoutDirection) private var layoutDirection + + private let pagingStrategy: PagingStrategy + + @Binding private var selection: [Page] + private let pages: [Page] + + @ViewBuilder private let content: (Page) -> Content + + private var singlePageSelection: Binding { + Binding( + get: { selection[0] }, + set: { selection = [$0] } + ) + } + + @ViewBuilder + private func contentView(for page: Page) -> some View { + content(page) + .environment(\.layoutDirection, layoutDirection) + } +} + +private struct QuranDoublePaginationView: View { + private struct DoublePage: Identifiable, Equatable { + let first: Page + let second: Page + + var id: [Page] { [first, second] } + } + + // MARK: Internal + + @Binding var selection: [Page] + let pages: [Page] + @ViewBuilder let content: (Page) -> Content + + var body: some View { + PageViewController( + transitionStyle: .scroll, + navigationOrientation: .horizontal, + interPageSpacing: ContentDimension.interPageSpacing, + animated: true, + selection: doublePageSelection + ) { + ForEach(doublePages) { doublePage in + HStack(spacing: 0) { + QuranSeparators.PageSideSeparator(leading: true) + content(doublePage.first) + QuranSeparators.PageMiddleSeparator() + content(doublePage.second) + QuranSeparators.PageSideSeparator(leading: false) + } + } + } + } + + // MARK: Private + + private var doublePageSelection: Binding { + Binding( + get: { + let pageIndex = selection.first.flatMap { pages.firstIndex(of: $0) } ?? 0 + return doublePages[pageIndex / 2] + }, + set: { selection = [$0.first, $0.second] } + ) + } + + private var doublePages: [DoublePage] { + stride(from: 0, to: pages.count, by: 2).map { + DoublePage(first: pages[$0], second: pages[$0 + 1]) + } + } +} + +private struct QuranSinglePaginationView: View { + // MARK: Internal + + @Binding var selection: Page + let pages: [Page] + @ViewBuilder let content: (Page) -> Content + + var body: some View { + PageViewController( + transitionStyle: .scroll, + navigationOrientation: .horizontal, + interPageSpacing: ContentDimension.interPageSpacing, + animated: true, + selection: $selection + ) { + ForEach(pages) { page in + if isRightSide(page) { + HStack(spacing: 0) { + QuranSeparators.PageSideSeparator(leading: true) + content(page) + QuranSeparators.PageMiddleSeparator() + .offset(x: middleOffset) + .padding(.leading, -middleOffset) + } + } else { + HStack(spacing: 0) { + content(page) + QuranSeparators.PageSideSeparator(leading: false) + } + } + } + } + } + + // MARK: Private + + private var middleOffset: CGFloat { + QuranSeparators.middleWidth + } + + private func isRightSide(_ page: Page) -> Bool { + page.pageNumber % 2 == 1 + } +} + +extension Page: Identifiable { + public var id: Int { pageNumber } +} + +struct QuranPaginationView_Previews: PreviewProvider { + struct QuranPaginationViewPreview: View { + static let quran = Quran.hafsMadani1405 + + @State var selection = [quran.pages[0]] + let pages = quran.pages + + let pagingStrategy: PagingStrategy + + var body: some View { + QuranPaginationView( + pagingStrategy: pagingStrategy, + selection: $selection, + pages: pages + ) { page in + ZStack { + VStack { + VStack(alignment: .leading) { + HStack { + Text("Page: \(page.pageNumber) left") + Spacer() + Text("Page: \(page.pageNumber) Right") + } + Spacer() + HStack { + Text("Page: \(page.pageNumber)") + Spacer() + Text("Page: \(page.pageNumber)") + } + Spacer() + HStack { + Text("Page: \(page.pageNumber)") + Spacer() + Text("Page: \(page.pageNumber)") + } + } + } + } + } + .environment(\.layoutDirection, .rightToLeft) + } + } + + // MARK: Internal + + static var previews: some View { + QuranPaginationViewPreview(pagingStrategy: .singlePage) + QuranPaginationViewPreview(pagingStrategy: .doublePage) + } +} diff --git a/Features/QuranPagesFeature/SinglePageController.swift b/Features/QuranPagesFeature/SinglePageController.swift deleted file mode 100644 index 7468df3d..00000000 --- a/Features/QuranPagesFeature/SinglePageController.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// SinglePageController.swift -// Quran -// -// Created by Mohamed Afifi on 2022-10-08. -// Copyright © 2022 Quran.com. All rights reserved. -// - -import NoorUI -import UIKit -import UIx - -class SinglePageController: UIViewController, PagesContainer { - // MARK: Lifecycle - - init(controller: UIViewController, isLeftSide: Bool) { - self.controller = controller - super.init(nibName: nil, bundle: nil) - configureView() - setUpSeparators(isLeftSide: isLeftSide) - installViewController(isLeftSide: isLeftSide) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Internal - - let controller: UIViewController - - var pages: [UIViewController] { [controller] } - - // MARK: Private - - private func configureView() { - loadViewIfNeeded() - view.backgroundColor = UIColor.reading - view.semanticContentAttribute = .forceRightToLeft - } - - private func installViewController(isLeftSide: Bool) { - addChild(controller) - view.addAutoLayoutSubview(controller.view) - controller.view.vc.verticalEdges() - controller.view.vc.leading(by: isLeftSide ? SidePageSeparator.width : 0) - controller.view.vc.trailing(by: !isLeftSide ? SidePageSeparator.width : 0) - controller.didMove(toParent: self) - } - - private func setUpSeparators(isLeftSide: Bool) { - if isLeftSide { - setUpLeftSideSeparators() - } else { - setUpRightSideSeparators() - } - } - - private func setUpLeftSideSeparators() { - let middleSeparator = MiddlePageSeparator() - view.addAutoLayoutSubview(middleSeparator) - middleSeparator.vc.verticalEdges().left(by: -1 * (MiddleSidePageSeparator.width + ContentDimension.interPageSpacing / 2)) - - let rightSeparator = SidePageSeparator.rightSide() - view.addAutoLayoutSubview(rightSeparator) - rightSeparator.vc.verticalEdges().right() - } - - private func setUpRightSideSeparators() { - let leftSeparator = SidePageSeparator.leftSide() - view.addAutoLayoutSubview(leftSeparator) - leftSeparator.vc.verticalEdges().left() - } -} diff --git a/Features/QuranPagesFeature/TwoPagesController.swift b/Features/QuranPagesFeature/TwoPagesController.swift deleted file mode 100644 index 44adaf12..00000000 --- a/Features/QuranPagesFeature/TwoPagesController.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// TwoPagesController.swift -// Quran -// -// Created by Mohamed Afifi on 2022-09-29. -// Copyright © 2022 Quran.com. All rights reserved. -// - -import UIKit -import UIx - -class TwoPagesController: UIViewController, PagesContainer { - // MARK: Lifecycle - - init(first: UIViewController, second: UIViewController) { - self.first = first - self.second = second - super.init(nibName: nil, bundle: nil) - configureView() - setUpSeparators() - installViewControllers() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Internal - - let first: UIViewController - let second: UIViewController - - var pages: [UIViewController] { [first, second] } - - // MARK: Private - - private func configureView() { - loadViewIfNeeded() - view.backgroundColor = UIColor.reading - view.semanticContentAttribute = .forceRightToLeft - } - - private func installViewControllers() { - for viewController in [first, second] { - addChild(viewController) - view.addAutoLayoutSubview(viewController.view) - viewController.view.vc.verticalEdges() - } - - first.view.vc.leading(by: SidePageSeparator.width) - second.view.vc.trailing(by: SidePageSeparator.width) - first.view.vc.width(to: second.view) - view.addSiblingHorizontalContiguous(left: first.view, right: second.view) - - for viewController in [first, second] { - viewController.didMove(toParent: self) - } - } - - private func setUpSeparators() { - let middleSeparator = MiddlePageSeparator() - view.addAutoLayoutSubview(middleSeparator) - middleSeparator.vc.verticalEdges().centerX() - - let rightSeparator = SidePageSeparator.rightSide() - view.addAutoLayoutSubview(rightSeparator) - rightSeparator.vc.verticalEdges().leading() - - let leftSeparator = SidePageSeparator.leftSide() - view.addAutoLayoutSubview(leftSeparator) - leftSeparator.vc.verticalEdges().trailing() - } -} diff --git a/Features/QuranPagesFeature/VerticalPageController.swift b/Features/QuranPagesFeature/VerticalPageController.swift deleted file mode 100644 index 80ea34dc..00000000 --- a/Features/QuranPagesFeature/VerticalPageController.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// VerticalPageController.swift -// Quran -// -// Created by Mohamed Afifi on 2022-10-08. -// Copyright © 2022 Quran.com. All rights reserved. -// - -import NoorUI -import UIKit -import UIx - -class VerticalPageController: UIViewController, PagesContainer { - // MARK: Lifecycle - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - init(controller: UIViewController) { - self.controller = controller - super.init(nibName: nil, bundle: nil) - configureView() - setUpSeparators() - installViewController() - } - - // MARK: Internal - - let controller: UIViewController - - var pages: [UIViewController] { [controller] } - - // MARK: Private - - private func configureView() { - loadViewIfNeeded() - view.backgroundColor = UIColor.reading - view.semanticContentAttribute = .forceRightToLeft - } - - private func installViewController() { - addChild(controller) - view.addAutoLayoutSubview(controller.view) - controller.view.vc.edges() - controller.didMove(toParent: self) - } - - private func setUpSeparators() { - let line = UIView() - line.backgroundColor = UIColor.pageSeparatorLine - view.addAutoLayoutSubview(line) - line.vc.horizontalEdges().height(by: 1).bottom(by: ContentDimension.interPageSpacing / 2) - } -} diff --git a/Features/QuranTranslationFeature/ContentTranslationBuilder.swift b/Features/QuranTranslationFeature/ContentTranslationBuilder.swift index 0ccce04e..3aec18d2 100644 --- a/Features/QuranTranslationFeature/ContentTranslationBuilder.swift +++ b/Features/QuranTranslationFeature/ContentTranslationBuilder.swift @@ -13,9 +13,11 @@ import Foundation import QuranKit import QuranPagesFeature import QuranTextKit +import ReadingService +import UIKit import Utilities -public struct ContentTranslationBuilder: PageDataSourceBuilder { +public struct ContentTranslationBuilder: PageViewBuilder { // MARK: Lifecycle public init(container: AppDependencies, highlightsService: QuranHighlightsService) { @@ -25,9 +27,11 @@ public struct ContentTranslationBuilder: PageDataSourceBuilder { // MARK: Public - public func build(actions: PageDataSourceActions, pages: [Page]) -> PageDataSource { + public func build() -> (Page) -> PageView { + let reading = ReadingPreferences.shared.reading + let pages = reading.quran.pages let dataService = createElementLoader(pages: pages) - return PageDataSource(actions: actions) { page in + return { page in ContentTranslationViewController(dataService: dataService, page: page, highlightsService: highlightsService) } } diff --git a/Features/QuranViewFeature/QuranInteractor.swift b/Features/QuranViewFeature/QuranInteractor.swift index 4a28a7c0..154d7052 100644 --- a/Features/QuranViewFeature/QuranInteractor.swift +++ b/Features/QuranViewFeature/QuranInteractor.swift @@ -261,7 +261,7 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis } func word(at point: CGPoint, in view: UIView) -> Word? { - contentViewModel?.word(at: point, in: view) + contentViewController?.word(at: point, in: view) } func highlightWord(_ word: Word?) { @@ -279,12 +279,6 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis } } - func setVisiblePages(_ pages: [Page]) { - logger.info("Quran: set visible pages \(pages)") - presenter?.setVisiblePages(pages) - showPageBookmarkIfNeeded(for: pages) - } - func userWillBeginDragScroll() { logger.info("Quran: userWillBeginDragScroll") presenter?.hideBars() @@ -329,27 +323,40 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis private var deps: Deps private let input: QuranInput - private var contentViewModel: ContentViewModel? private var audioBanner: AudioBannerViewModel? - private var cancellables: Set = [] - private var isWordPointerActive: Bool = false - private var wordPointer: WordPointerViewController? + private var visiblePageCancellable: AnyCancellable? + + private var contentViewController: ContentViewController? + + private var contentViewModel: ContentViewModel? { + didSet { + visiblePageCancellable = contentViewModel?.$visiblePages + .sink { [weak self] in self?.setVisiblePages($0) } + } + } + private var pageBookmarks: [PageBookmark] = [] { didSet { reloadPageBookmark() } } + private func setVisiblePages(_ pages: [Page]) { + logger.info("Quran: set visible pages \(pages)") + presenter?.setVisiblePages(pages) + showPageBookmarkIfNeeded(for: pages) + } + private func loadContent() { let (viewController, viewModel) = deps.audioBannerBuilder.build(withListener: self) audioBanner = viewModel presenter?.presentAudioBanner(viewController) - contentViewModel = presentQuranContent(with: input) + (contentViewController, contentViewModel) = presentQuranContent(with: input) presenter?.startHiddenBarsTimer() } @@ -405,10 +412,10 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis // MARK: - Quran Content - private func presentQuranContent(with input: QuranInput) -> ContentViewModel { + private func presentQuranContent(with input: QuranInput) -> (ContentViewController, ContentViewModel) { let (viewController, contentViewModel) = deps.contentBuilder.build(withListener: self, input: input) presenter?.presentQuranContent(viewController) - return contentViewModel + return (viewController, contentViewModel) } // MARK: - Page Bookmark diff --git a/Package.swift b/Package.swift index fceac5ce..e6f9489f 100644 --- a/Package.swift +++ b/Package.swift @@ -173,6 +173,7 @@ private func uiTargets() -> [[Target]] { "QuranAnnotations", "QuranGeometry", "NoorFont", + "VLogging", .product(name: "GenericDataSources", package: "GenericDataSource"), ]), ] diff --git a/UI/NoorUI/Miscellaneous/ContentDimension.swift b/UI/NoorUI/Miscellaneous/ContentDimension.swift index 590363db..e2ee34a1 100644 --- a/UI/NoorUI/Miscellaneous/ContentDimension.swift +++ b/UI/NoorUI/Miscellaneous/ContentDimension.swift @@ -13,7 +13,7 @@ public enum ContentDimension { public static let interSpacing: CGFloat = 8 - public static let interPageSpacing: CGFloat = 10 + public static let interPageSpacing: CGFloat = 12 public static func insets(of view: UIView) -> NSDirectionalEdgeInsets { let readableInsets = view.window?.safeAreaInsets ?? .zero diff --git a/UI/NoorUI/Pager/PageViewController.swift b/UI/NoorUI/Pager/PageViewController.swift new file mode 100644 index 00000000..47963b17 --- /dev/null +++ b/UI/NoorUI/Pager/PageViewController.swift @@ -0,0 +1,309 @@ +// +// PageViewController.swift +// +// +// Created by Mohamed Afifi on 2023-12-23. +// + +// Most of the code is copied from https://github.com/benjaminsage/iPages + +import SwiftUI +import UIKit +import UIx +import VLogging + +public struct PageViewController: View + where Element: Identifiable, + Element: Equatable, + Content: View +{ + // MARK: Lifecycle + + public init( + transitionStyle: UIPageViewController.TransitionStyle, + navigationOrientation: UIPageViewController.NavigationOrientation, + interPageSpacing: CGFloat, + animated: Bool, + selection: Binding, + @ViewBuilder forEach: () -> ForEach<[Element], Element.ID, Content> + ) { + self.transitionStyle = transitionStyle + self.navigationOrientation = navigationOrientation + self.interPageSpacing = interPageSpacing + self.animated = animated + _selection = selection + self.forEach = forEach() + } + + // MARK: Public + + public var body: some View { + _PageViewController( + transitionStyle: transitionStyle, + navigationOrientation: navigationOrientation, + interPageSpacing: interPageSpacing, + animated: animated, + forEach: forEach, + selection: $selection + ) + } + + // MARK: Internal + + let transitionStyle: UIPageViewController.TransitionStyle + let navigationOrientation: UIPageViewController.NavigationOrientation + let interPageSpacing: CGFloat + let animated: Bool + + @Binding var selection: Element + let forEach: ForEach<[Element], Element.ID, Content> +} + +private struct _PageViewController: UIViewControllerRepresentable + where + Element: Identifiable, + Element: Equatable, + Content: View +{ + let transitionStyle: UIPageViewController.TransitionStyle + let navigationOrientation: UIPageViewController.NavigationOrientation + let interPageSpacing: CGFloat + let animated: Bool + + let forEach: ForEach<[Element], Element.ID, Content> + @Binding var selection: Element + + @State var userDraggingStartedTransitionInProgress = false + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIViewController(context: Context) -> UIPageViewController { + let options: [UIPageViewController.OptionsKey: Any] = [ + .interPageSpacing: interPageSpacing, + ] + let pageViewController = UIPageViewController( + transitionStyle: transitionStyle, + navigationOrientation: navigationOrientation, + options: options + ) + + pageViewController.dataSource = context.coordinator + pageViewController.delegate = context.coordinator + + pageViewController.view.backgroundColor = .clear + + for view in pageViewController.view.subviews { + if let scrollView = view as? UIScrollView { + scrollView.delegate = context.coordinator + break + } + } + + // Trigger an update. + updateUIViewController(pageViewController, context: context) + + return pageViewController + } + + func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) { + // Early return if showing selection's view controller. + if let visibleController = pageViewController.viewControllers?.first as? PageContentController { + if visibleController.element == selection { + return + } + } + + if userDraggingStartedTransitionInProgress { + logger.info("Cannot change page while user dragging in progress") + + // when user dragging initiated transition is still in progress, + // prevent the app from starting simultaneous transitions to avoid assertion failure and crash + // reference: https://github.com/hons82/THSegmentedPager/blob/master/THSegmentedPager/THSegmentedPager.m#L233 + + // failure type 1: Assertion failure in + // -[UIPageViewController queuingScrollView:didEndManualScroll:toRevealView:direction:animated:didFinish:didComplete:], + // /SourceCache/UIKit_Sim/UIKit-2935.137/UIPageViewController.m:1866 + // Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'No view controller managing visible view + + // failure type 2: Assertion failure in -[_UIQueuingScrollView _enqueueCompletionState:], + // /SourceCache/UIKit_Sim/UIKit-2935.137/_UIQueuingScrollView.m:499 + // Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Duplicate states in queue' + return + } + + let previousSelection = context.coordinator.parent.selection + context.coordinator.parent = self + + let viewController = makeController(selection) + + let previousIndex = forEach.data.firstIndex { $0 == previousSelection } + let currentIndex = forEach.data.firstIndex { $0 == selection } + let direction: UIPageViewController.NavigationDirection + if let previousIndex, let currentIndex { + direction = currentIndex < previousIndex ? .forward : .reverse + } else { + direction = .forward + } + + pageViewController.setViewControllers([viewController], direction: direction, animated: animated) + } + + func makeController(_ element: Element) -> UIViewController { + let view = forEach.content(element) + return PageContentController(rootView: view, element: element) + } +} + +// MARK: - Coordinator + +extension _PageViewController { + class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate, UIScrollViewDelegate { + // MARK: Lifecycle + + init(_ pageViewController: _PageViewController) { + parent = pageViewController + } + + // MARK: Internal + + var parent: _PageViewController + + func pageViewController( + _ pageViewController: UIPageViewController, + viewControllerBefore viewController: UIViewController + ) -> UIViewController? { + guard let contentController = viewController as? PageContentController else { + return nil + } + + guard let index = parent.forEach.data.firstIndex(of: contentController.element) else { + return nil + } + + let newIndex = index - 1 + if newIndex < 0 { + return nil + } + let element = parent.forEach.data[newIndex] + return parent.makeController(element) + } + + func pageViewController( + _ pageViewController: UIPageViewController, + viewControllerAfter viewController: UIViewController + ) -> UIViewController? { + guard let contentController = viewController as? PageContentController else { + return nil + } + + guard let index = parent.forEach.data.firstIndex(of: contentController.element) else { + return nil + } + + let newIndex = index + 1 + + if newIndex >= parent.forEach.data.count { + return nil + } + let element = parent.forEach.data[newIndex] + return parent.makeController(element) + } + + func pageViewController( + _ pageViewController: UIPageViewController, + didFinishAnimating finished: Bool, + previousViewControllers: [UIViewController], + transitionCompleted completed: Bool + ) { + if completed, + let visibleViewController = pageViewController.viewControllers?.first, + let contentController = visibleViewController as? PageContentController + { + parent.selection = contentController.element + } + } + + // MARK: - UIScrollViewDelegate + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollView.isTracking || scrollView.isDecelerating { + parent.userDraggingStartedTransitionInProgress = true + } + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + parent.userDraggingStartedTransitionInProgress = false + } + } +} + +extension _PageViewController { + private class PageContentController: UIHostingController { + // MARK: Lifecycle + + init(rootView: Content, element: Element) { + self.element = element + super.init(rootView: rootView) + view.backgroundColor = .clear + _disableSafeAreaInsets() + } + + @available(*, unavailable) + @objc + dynamic required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Internal + + let element: Element + + var description1: String { + "" + } + } +} + +struct PaginationView_Previews: PreviewProvider { + struct PaginationViewPreview: View { + struct Page: Identifiable, Equatable { let id: Int } + + // MARK: Internal + + let pages = (0 ..< 604).map(Page.init) + @State var currentPage = Page(id: 45) + + var body: some View { + PageViewController( + transitionStyle: .scroll, + navigationOrientation: .horizontal, + interPageSpacing: 10, + animated: true, + selection: $currentPage + ) { + ForEach(pages) { page in + VStack { + Text("Top") + Spacer() + Text("Page: \(page.id)") + Spacer() + Text("Top") + } + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + .background(page.id % 2 == 0 ? Color.red : Color.green) + } + } + .ignoresSafeArea() + .background(Color.blue) + .border(Color.purple) + } + } + + // MARK: Internal + + static var previews: some View { + PaginationViewPreview() + } +} diff --git a/UI/NoorUI/Pager/QuranPageSeparators.swift b/UI/NoorUI/Pager/QuranPageSeparators.swift new file mode 100644 index 00000000..f83f8d3d --- /dev/null +++ b/UI/NoorUI/Pager/QuranPageSeparators.swift @@ -0,0 +1,140 @@ +// +// QuranPageSeparators.swift +// +// +// Created by Mohamed Afifi on 2023-12-25. +// + +import SwiftUI + +public enum QuranSeparators { + // MARK: Public + + public static let middleWidth: CGFloat = ContentDimension.interPageSpacing + + // MARK: Internal + + static let gradient = [Color(.pageSeparatorBackground), Color(.reading)] + static let line = Color(.pageSeparatorLine) + + static let sideWidth: CGFloat = 10 +} + +extension QuranSeparators { + public struct PageSideSeparator: View { + // MARK: Lifecycle + + public init(leading: Bool) { + self.leading = leading + } + + // MARK: Public + + public var body: some View { + ZStack { + LinearGradient( + gradient: Gradient(colors: directionalGradientColors), + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: width) + + // Adding lines on top of the gradient + ForEach(0 ..< lines, id: \.self) { i in + let step = width / CGFloat(lines - 1) + let distance = CGFloat(i) * step + + Rectangle() + .fill(lineColor) + .frame(width: lineWidth) + .offset(x: distance - width / 2) + } + } + .flipsForRightToLeftLayoutDirection(true) + } + + // MARK: Internal + + let gradientColors = QuranSeparators.gradient + let lineColor = QuranSeparators.line + let lines: Int = 5 + let width: CGFloat = QuranSeparators.sideWidth + let lineWidth: CGFloat = 0.5 + + let leading: Bool + + var directionalGradientColors: [Color] { + leading ? gradientColors : gradientColors.reversed() + } + } + + public struct PageMiddleSeparator: View { + // MARK: Lifecycle + + public init() { + } + + // MARK: Public + + public var body: some View { + ZStack { + HStack(spacing: 0) { + LinearGradient( + gradient: Gradient(colors: gradientColors), + startPoint: .trailing, + endPoint: .leading + ) + .frame(width: width) + + LinearGradient( + gradient: Gradient(colors: gradientColors), + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: width) + } + + // Adding 1 line on top of the gradients + Rectangle() + .fill(lineColor) + .frame(width: lineWidth) + } + .flipsForRightToLeftLayoutDirection(true) + } + + // MARK: Internal + + let gradientColors = QuranSeparators.gradient + let lineColor = QuranSeparators.line + let width: CGFloat = QuranSeparators.middleWidth / 2 + let lineWidth: CGFloat = 0.7 + + var directionalGradientColors: [Color] { + gradientColors + } + } +} + +struct QuranPageSeparators_Previews: PreviewProvider { + struct QuranPageSeparatorsPreview: View { + var body: some View { + HStack { + QuranSeparators.PageSideSeparator(leading: true) + Spacer() + QuranSeparators.PageMiddleSeparator() + Spacer() + QuranSeparators.PageSideSeparator(leading: false) + } + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + .background(Color(.reading)) + .ignoresSafeArea() + .environment(\.layoutDirection, .rightToLeft) + } + } + + // MARK: Internal + + static var previews: some View { + QuranPageSeparatorsPreview() + } +} diff --git a/UI/UIx/SwiftUI/Miscellaneous/DisableSafeAreaInsets.swift b/UI/UIx/SwiftUI/Miscellaneous/DisableSafeAreaInsets.swift new file mode 100644 index 00000000..fda003a8 --- /dev/null +++ b/UI/UIx/SwiftUI/Miscellaneous/DisableSafeAreaInsets.swift @@ -0,0 +1,54 @@ +// +// DisableSafeAreaInsets.swift +// +// +// Created by Mohamed Afifi on 2023-12-25. +// + +import SwiftUI + +// From:https://github.com/SwiftUIX/SwiftUIX/blob/2bf8eda3acad39b0419e4053d321059030cfa04b/Sources/SwiftUIX/Intramodular/Bridging/CocoaHostingController.swift#L263C17-L263C39 + +extension UIHostingController { + /// https://twitter.com/b3ll/status/1193747288302075906 + public func _disableSafeAreaInsets() { + guard let viewClass = object_getClass(view), !String(cString: class_getName(viewClass)).hasSuffix("_SwiftUIX_patched") else { + return + } + + let className = String(cString: class_getName(viewClass)).appending("_SwiftUIX_patched") + + if let viewSubclass = NSClassFromString(className) { + object_setClass(view, viewSubclass) + } else { + className.withCString { className in + guard let subclass = objc_allocateClassPair(viewClass, className, 0) else { + return + } + + if let method = class_getInstanceMethod(UIView.self, #selector(getter: UIView.safeAreaInsets)) { + let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in + .zero + } + + class_addMethod(subclass, #selector(getter: UIView.safeAreaInsets), imp_implementationWithBlock(safeAreaInsets), method_getTypeEncoding(method)) + } + + if let method2 = class_getInstanceMethod(viewClass, #selector(getter: UIView.safeAreaLayoutGuide)) { + let safeAreaLayoutGuide: @convention(block) (AnyObject) -> UILayoutGuide? = { (_: AnyObject!) -> UILayoutGuide? in + nil + } + + class_replaceMethod(viewClass, #selector(getter: UIView.safeAreaLayoutGuide), imp_implementationWithBlock(safeAreaLayoutGuide), method_getTypeEncoding(method2)) + } + + objc_registerClassPair(subclass) + object_setClass(view, subclass) + } + + view.setNeedsDisplay() + view.setNeedsLayout() + view.layoutIfNeeded() + } + } +} diff --git a/UI/UIx/SwiftUI/Views/StaticViewControllerRepresentable.swift b/UI/UIx/SwiftUI/Views/StaticViewControllerRepresentable.swift new file mode 100644 index 00000000..95b6ac95 --- /dev/null +++ b/UI/UIx/SwiftUI/Views/StaticViewControllerRepresentable.swift @@ -0,0 +1,26 @@ +// +// StaticViewControllerRepresentable.swift +// +// +// Created by Mohamed Afifi on 2023-12-25. +// + +import SwiftUI + +public struct StaticViewControllerRepresentable: UIViewControllerRepresentable { + // MARK: Lifecycle + + public init(viewController: UIViewController) { + self.viewController = viewController + } + + // MARK: Public + + public let viewController: UIViewController + + public func makeUIViewController(context: Context) -> UIViewController { + viewController + } + + public func updateUIViewController(_ uiViewController: UIViewController, context: Context) { } +}