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) { }
+}