diff --git a/Features/QuranContentFeature/ContentViewController.swift b/Features/QuranContentFeature/ContentViewController.swift index 46b14ad5..7e9b59a0 100644 --- a/Features/QuranContentFeature/ContentViewController.swift +++ b/Features/QuranContentFeature/ContentViewController.swift @@ -47,9 +47,14 @@ public final class ContentViewController: UIViewController, UIGestureRecognizerD true } - public func word(at point: CGPoint, in view: UIView) -> Word? { - convert(point, from: view) - .flatMap { $0.word(at: $1) } + public func word(at point: CGPoint) -> Word? { + let actions = viewModel.geometryActions + for action in actions { + if let word = action.word(point) { + return word + } + } + return nil } // MARK: Internal @@ -65,40 +70,21 @@ public final class ContentViewController: UIViewController, UIGestureRecognizerD private let viewModel: ContentViewModel - private var pageViews: [PageView] { - findPageViews(in: self) - } - private func setUpPagesView() { + let viewModel = viewModel let pagesView = PagesView(viewModel: viewModel) let pagingController = UIHostingController(rootView: pagesView) addFullScreenChild(pagingController) } - private func verse(at point: CGPoint, in view: UIView) -> AyahNumber? { - convert(point, from: view) - .flatMap { $0.verse(at: $1) } - } - - 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) + private func verse(at point: CGPoint) -> AyahNumber? { + let actions = viewModel.geometryActions + for action in actions { + if let verse = action.verse(point) { + return verse } - - // Recursively search in the child's children - result.append(contentsOf: findPageViews(in: child)) } - - return result + return nil } // MARK: - Gestures @@ -120,14 +106,15 @@ public final class ContentViewController: UIViewController, UIGestureRecognizerD } let point = sender.location(in: targetView) + let globalPoint = sender.location(in: nil) switch sender.state { case .began: - if let verse = verse(at: point, in: targetView) { + if let verse = verse(at: globalPoint) { viewModel.onViewLongPressStarted(at: point, sourceView: targetView, verse: verse) } case .changed: - if let verse = verse(at: point, in: targetView) { + if let verse = verse(at: globalPoint) { viewModel.onViewLongPressChanged(to: point, verse: verse) } case .ended: @@ -137,32 +124,3 @@ public final class ContentViewController: UIViewController, UIGestureRecognizerD } } } - -private struct PagesView: View { - @ObservedObject var viewModel: ContentViewModel - - var body: some View { - GeometryReader { geometry in - QuranPaginationView( - pagingStrategy: pagingStrategy(with: geometry), - selection: $viewModel.visiblePages, - pages: viewModel.deps.quran.pages - ) { page in - StaticViewControllerRepresentable(viewController: viewModel.pageViewBuilder.build(at: page)) - } - .id(viewModel.quranMode) - } - } - - func pagingStrategy(with geometry: GeometryProxy) -> PagingStrategy { - if geometry.size.height > geometry.size.width { - return .singlePage - } - - if !TwoPagesUtils.hasEnoughHorizontalSpace() { - return .singlePage - } - - return viewModel.pagingStrategy - } -} diff --git a/Features/QuranContentFeature/ContentViewModel.swift b/Features/QuranContentFeature/ContentViewModel.swift index 7a2d9b9e..d3183bdc 100644 --- a/Features/QuranContentFeature/ContentViewModel.swift +++ b/Features/QuranContentFeature/ContentViewModel.swift @@ -11,10 +11,12 @@ import AnnotationsService import Combine import Crashing import QuranAnnotations +import QuranImageFeature import QuranKit import QuranPagesFeature import QuranText import QuranTextKit +import QuranTranslationFeature import TranslationService import UIKit import VLogging @@ -38,8 +40,8 @@ public final class ContentViewModel: ObservableObject { let highlightsService: QuranHighlightsService - let imageDataSourceBuilder: PageViewBuilder - let translationDataSourceBuilder: PageViewBuilder + let imageDataSourceBuilder: ContentImageBuilder + let translationDataSourceBuilder: ContentTranslationBuilder } private struct LongPressData { @@ -108,6 +110,7 @@ public final class ContentViewModel: ObservableObject { @Published var quranMode: QuranMode @Published var twoPagesEnabled: Bool + @Published var geometryActions: [PageGeometryActions] = [] @Published var highlights: QuranHighlights { didSet { @@ -125,13 +128,6 @@ public final class ContentViewModel: ObservableObject { twoPagesEnabled ? .doublePage : .singlePage } - var pageViewBuilder: PageViewBuilder { - switch deps.quranContentStatePreferences.quranMode { - case .arabic: return deps.imageDataSourceBuilder - case .translation: return deps.translationDataSourceBuilder - } - } - func onViewLongPressStarted(at point: CGPoint, sourceView: UIView, verse: AyahNumber) { longPressData = LongPressData( sourceView: sourceView, diff --git a/Features/QuranContentFeature/PagesView.swift b/Features/QuranContentFeature/PagesView.swift new file mode 100644 index 00000000..a97202dc --- /dev/null +++ b/Features/QuranContentFeature/PagesView.swift @@ -0,0 +1,49 @@ +// +// PagesView.swift +// +// +// Created by Mohamed Afifi on 2024-10-06. +// + +import QuranPagesFeature +import QuranTextKit +import SwiftUI +import UIx + +struct PagesView: View { + @StateObject var viewModel: ContentViewModel + + var body: some View { + GeometryReader { geometry in + QuranPaginationView( + pagingStrategy: pagingStrategy(with: geometry), + selection: $viewModel.visiblePages, + pages: viewModel.deps.quran.pages + ) { page in + Group { + switch viewModel.quranMode { + case .arabic: + viewModel.deps.imageDataSourceBuilder.build(at: page) + case .translation: + viewModel.deps.translationDataSourceBuilder.build(at: page) + } + } + } + .id(viewModel.quranMode) + } + .collectGeometryActions($viewModel.geometryActions) + } + + private func pagingStrategy(with geometry: GeometryProxy) -> PagingStrategy { + // If portrait + if geometry.size.height > geometry.size.width { + return .singlePage + } + + if !TwoPagesUtils.hasEnoughHorizontalSpace() { + return .singlePage + } + + return viewModel.pagingStrategy + } +} diff --git a/Features/QuranImageFeature/ContentImageBuilder.swift b/Features/QuranImageFeature/ContentImageBuilder.swift index 982eed90..bfd18974 100644 --- a/Features/QuranImageFeature/ContentImageBuilder.swift +++ b/Features/QuranImageFeature/ContentImageBuilder.swift @@ -13,12 +13,13 @@ import ImageService import QuranKit import QuranPagesFeature import ReadingService +import SwiftUI import UIKit import Utilities import VLogging @MainActor -public struct ContentImageBuilder: PageViewBuilder { +public struct ContentImageBuilder { // MARK: Lifecycle public init(container: AppDependencies, highlightsService: QuranHighlightsService) { @@ -28,19 +29,17 @@ public struct ContentImageBuilder: PageViewBuilder { // MARK: Public - public func build(at page: Page) -> PageView { + public func build(at page: Page) -> some View { let reading = ReadingPreferences.shared.reading let imageService = Self.buildImageDataService(reading: reading, container: container) - return ContentImageViewController( + let viewModel = ContentImageViewModel( + reading: reading, page: page, - viewModel: ContentImageViewModel( - reading: reading, - page: page, - imageDataService: imageService, - highlightsService: highlightsService - ) + imageDataService: imageService, + highlightsService: highlightsService ) + return ContentImageView(viewModel: viewModel) } // MARK: Internal diff --git a/Features/QuranImageFeature/ContentImageView.swift b/Features/QuranImageFeature/ContentImageView.swift index 641b9a46..bdb7f453 100644 --- a/Features/QuranImageFeature/ContentImageView.swift +++ b/Features/QuranImageFeature/ContentImageView.swift @@ -8,10 +8,11 @@ import NoorUI import QuranGeometry import QuranKit +import QuranPagesFeature import SwiftUI struct ContentImageView: View { - @ObservedObject var viewModel: ContentImageViewModel + @StateObject var viewModel: ContentImageViewModel var body: some View { VStack { @@ -27,6 +28,13 @@ struct ContentImageView: View { onGlobalFrameChange: { viewModel.imageFrame = $0 } ) } + .geometryActions( + PageGeometryActions( + id: ObjectIdentifier(viewModel), + word: { point in viewModel.wordAtGlobalPoint(point) }, + verse: { point in viewModel.wordAtGlobalPoint(point)?.verse } + ) + ) .task { await viewModel.loadImagePage() } diff --git a/Features/QuranImageFeature/ContentImageViewController.swift b/Features/QuranImageFeature/ContentImageViewController.swift deleted file mode 100644 index 67ef3dc0..00000000 --- a/Features/QuranImageFeature/ContentImageViewController.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// ContentImageViewController.swift -// Quran -// -// Created by Afifi, Mohamed on 1/1/20. -// Copyright © 2020 Quran.com. All rights reserved. -// - -import QuranKit -import QuranPagesFeature -import SwiftUI - -private struct ContentImageViewStateHolder: View { - @StateObject var viewModel: ContentImageViewModel - - var body: some View { - ContentImageView(viewModel: viewModel) - } -} - -class ContentImageViewController: UIViewController, PageView { - // MARK: Lifecycle - - private let viewModel: ContentImageViewModel - - init(page: Page, viewModel: ContentImageViewModel) { - self.viewModel = viewModel - self.page = page - super.init(nibName: nil, bundle: nil) - - let view = ContentImageViewStateHolder(viewModel: viewModel) - let viewController = UIHostingController(rootView: view) - viewController._disableSafeArea = true - viewController.view.backgroundColor = .clear - addFullScreenChild(viewController) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Internal - - let page: Page - - func word(at point: CGPoint) -> Word? { - let globalPoint = view.convert(point, to: view.window) - return viewModel.wordAtGlobalPoint(globalPoint) - } - - func verse(at point: CGPoint) -> AyahNumber? { - word(at: point)?.verse - } -} diff --git a/Features/QuranPagesFeature/PageGeometryActions.swift b/Features/QuranPagesFeature/PageGeometryActions.swift new file mode 100644 index 00000000..7c285fb2 --- /dev/null +++ b/Features/QuranPagesFeature/PageGeometryActions.swift @@ -0,0 +1,79 @@ +// +// PageGeometryActions.swift +// +// +// Created by Mohamed Afifi on 2024-10-06. +// + +import Foundation +import QuranKit +import SwiftUI + +@MainActor +public struct PageGeometryActions: Equatable { + let id: AnyHashable + public var word: (CGPoint) -> Word? + public var verse: (CGPoint) -> AyahNumber? + + public init(id: some Hashable, word: @escaping (CGPoint) -> Word?, verse: @escaping (CGPoint) -> AyahNumber?) { + self.id = id + self.word = word + self.verse = verse + } + + public nonisolated static func == (lhs: PageGeometryActions, rhs: PageGeometryActions) -> Bool { + return lhs.id == rhs.id + } +} + +private struct PageGeometryActionsPreferenceKey: PreferenceKey { + public static var defaultValue: [PageGeometryActions] = [] + public static func reduce(value: inout [PageGeometryActions], nextValue: () -> [PageGeometryActions]) { + value.append(contentsOf: nextValue()) + } +} + +@MainActor +private struct PageGeometryActionsViewModifier: ViewModifier { + let actions: PageGeometryActions + @State private var frame: CGRect = .zero + + func body(content: Content) -> some View { + content + .preference(key: PageGeometryActionsPreferenceKey.self, value: [actions]) + .onGlobalFrameChanged { + frame = $0 + } + } + + private var wrappedActions: PageGeometryActions { + PageGeometryActions( + id: actions.id, + word: { point in + actions.word(toLocalPoint(point)) + }, + verse: { point in + actions.verse(toLocalPoint(point)) + } + ) + } + + func toLocalPoint(_ globalPoint: CGPoint) -> CGPoint { + CGPoint( + x: globalPoint.x - frame.minX, + y: globalPoint.y - frame.minY + ) + } +} + +extension View { + public func geometryActions(_ actions: PageGeometryActions) -> some View { + modifier(PageGeometryActionsViewModifier(actions: actions)) + } + + public func collectGeometryActions(_ actions: Binding<[PageGeometryActions]>) -> some View { + onPreferenceChange(PageGeometryActionsPreferenceKey.self) { + actions.wrappedValue = $0 + } + } +} diff --git a/Features/QuranPagesFeature/PageViewBuilder.swift b/Features/QuranPagesFeature/PageViewBuilder.swift deleted file mode 100644 index c000ece9..00000000 --- a/Features/QuranPagesFeature/PageViewBuilder.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// 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(at page: 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/QuranTranslationFeature/ContentTranslationBuilder.swift b/Features/QuranTranslationFeature/ContentTranslationBuilder.swift index 2c70f8be..6da12626 100644 --- a/Features/QuranTranslationFeature/ContentTranslationBuilder.swift +++ b/Features/QuranTranslationFeature/ContentTranslationBuilder.swift @@ -11,9 +11,10 @@ import AppDependencies import QuranKit import QuranPagesFeature import QuranTextKit +import SwiftUI import TranslationService -public struct ContentTranslationBuilder: PageViewBuilder { +public struct ContentTranslationBuilder { private let container: AppDependencies private let highlightsService: QuranHighlightsService @@ -22,7 +23,8 @@ public struct ContentTranslationBuilder: PageViewBuilder { self.highlightsService = highlightsService } - public func build(at page: Page) -> PageView { + @MainActor + public func build(at page: Page) -> some View { let dataService = QuranTextDataService( databasesURL: container.databasesURL, quranFileURL: container.quranUthmaniV2Database @@ -34,6 +36,7 @@ public struct ContentTranslationBuilder: PageViewBuilder { dataService: dataService, highlightsService: highlightsService ) - return ContentTranslationViewController(page: page, viewModel: viewModel) + viewModel.verses = page.verses + return ContentTranslationView(viewModel: viewModel) } } diff --git a/Features/QuranTranslationFeature/ContentTranslationView.swift b/Features/QuranTranslationFeature/ContentTranslationView.swift index e297cce2..e93191e1 100644 --- a/Features/QuranTranslationFeature/ContentTranslationView.swift +++ b/Features/QuranTranslationFeature/ContentTranslationView.swift @@ -7,16 +7,17 @@ import NoorUI import QuranKit +import QuranPagesFeature import QuranText import SwiftUI import UIx import Utilities public struct ContentTranslationView: View { - @ObservedObject var viewModel: ContentTranslationViewModel + @StateObject var viewModel: ContentTranslationViewModel - public init(viewModel: ContentTranslationViewModel) { - self.viewModel = viewModel + public init(viewModel: @autoclosure @escaping () -> ContentTranslationViewModel) { + _viewModel = StateObject(wrappedValue: viewModel()) } public var body: some View { @@ -30,6 +31,13 @@ public struct ContentTranslationView: View { footnote: $viewModel.footnote, openURL: { viewModel.openURL($0) } ) + .geometryActions( + PageGeometryActions( + id: ObjectIdentifier(viewModel), + word: { _ in nil }, + verse: { point in viewModel.ayahAtPoint(point) } + ) + ) .task(id: Pair(viewModel.verses, viewModel.selectedTranslations)) { await viewModel.load() } diff --git a/Features/QuranTranslationFeature/ContentTranslationViewController.swift b/Features/QuranTranslationFeature/ContentTranslationViewController.swift deleted file mode 100644 index 508b3087..00000000 --- a/Features/QuranTranslationFeature/ContentTranslationViewController.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// ContentTranslationViewController.swift -// Quran -// -// Created by Afifi, Mohamed on 1/1/20. -// Copyright © 2020 Quran.com. All rights reserved. -// - -import QuranKit -import QuranPagesFeature -import SwiftUI -import UIKit - -private struct ContentTranslationViewStateHolder: View { - @StateObject var viewModel: ContentTranslationViewModel - - var body: some View { - ContentTranslationView(viewModel: viewModel) - } -} - -class ContentTranslationViewController: UIViewController, PageView { - // MARK: Lifecycle - - private let viewModel: ContentTranslationViewModel - - init(page: Page, viewModel: ContentTranslationViewModel) { - self.viewModel = viewModel - self.page = page - super.init(nibName: nil, bundle: nil) - - viewModel.verses = page.verses - - let view = ContentTranslationViewStateHolder(viewModel: viewModel) - let viewController = UIHostingController(rootView: view) - viewController._disableSafeArea = true - viewController.view.backgroundColor = .clear - addFullScreenChild(viewController) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Internal - - let page: Page - - func word(at point: CGPoint) -> Word? { - nil - } - - func verse(at point: CGPoint) -> AyahNumber? { - viewModel.ayahAtPoint(point, from: view) - } -} diff --git a/Features/QuranTranslationFeature/ContentTranslationViewModel.swift b/Features/QuranTranslationFeature/ContentTranslationViewModel.swift index 0d0abc56..ae9ffe1a 100644 --- a/Features/QuranTranslationFeature/ContentTranslationViewModel.swift +++ b/Features/QuranTranslationFeature/ContentTranslationViewModel.swift @@ -205,8 +205,8 @@ public final class ContentTranslationViewModel: ObservableObject { } } - func ayahAtPoint(_ point: CGPoint, from: UICoordinateSpace) -> AyahNumber? { - tracker.itemAtPoint(point, from: from)?.ayah + func ayahAtPoint(_ point: CGPoint) -> AyahNumber? { + tracker.itemAtPoint(point)?.ayah } // MARK: Private diff --git a/Features/QuranViewFeature/QuranInteractor.swift b/Features/QuranViewFeature/QuranInteractor.swift index 97455f69..370a5d00 100644 --- a/Features/QuranViewFeature/QuranInteractor.swift +++ b/Features/QuranViewFeature/QuranInteractor.swift @@ -256,8 +256,8 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis presenter?.hideBars() } - func word(at point: CGPoint, in view: UIView) -> Word? { - contentViewController?.word(at: point, in: view) + func word(at point: CGPoint) -> Word? { + contentViewController?.word(at: point) } func highlightWord(_ word: Word?) { diff --git a/Features/WordPointerFeature/WordPointerViewController.swift b/Features/WordPointerFeature/WordPointerViewController.swift index cd84b52c..e94d9c3b 100644 --- a/Features/WordPointerFeature/WordPointerViewController.swift +++ b/Features/WordPointerFeature/WordPointerViewController.swift @@ -213,7 +213,8 @@ public final class WordPointerViewController: UIViewController { showMagnifyingGlassIfNeeded() moveMagnifyingGlass(to: arrowPoint) - let status = await viewModel.viewPanned(to: arrowPoint, in: container) + let globalPoint = view.convert(arrowPoint, to: nil) + let status = await viewModel.viewPanned(to: globalPoint) switch status { case .none: break diff --git a/Features/WordPointerFeature/WordPointerViewModel.swift b/Features/WordPointerFeature/WordPointerViewModel.swift index 33ea8b28..582401c7 100644 --- a/Features/WordPointerFeature/WordPointerViewModel.swift +++ b/Features/WordPointerFeature/WordPointerViewModel.swift @@ -15,7 +15,7 @@ import WordTextService @MainActor public protocol WordPointerListener: AnyObject { func onWordPointerPanBegan() - func word(at point: CGPoint, in view: UIView) -> Word? + func word(at point: CGPoint) -> Word? func highlightWord(_ position: Word?) } @@ -41,8 +41,8 @@ final class WordPointerViewModel { listener?.onWordPointerPanBegan() } - func viewPanned(to point: CGPoint, in view: UIView) async -> PanResult { - guard let word = listener?.word(at: point, in: view) else { + func viewPanned(to point: CGPoint) async -> PanResult { + guard let word = listener?.word(at: point) else { logger.debug("No word found at position \(point)") unhighlightWord() return .hidePopover diff --git a/UI/UIx/SwiftUI/Views/CollectionTracker.swift b/UI/UIx/SwiftUI/Views/CollectionTracker.swift index 78335e40..b766a12b 100644 --- a/UI/UIx/SwiftUI/Views/CollectionTracker.swift +++ b/UI/UIx/SwiftUI/Views/CollectionTracker.swift @@ -76,9 +76,9 @@ public final class CollectionTracker { return [] } - public func itemAtPoint(_ point: CGPoint, from: UICoordinateSpace) -> Item? { + public func itemAtPoint(_ point: CGPoint) -> Item? { for visibleView in visibleViews { - let localPoint = visibleView.convert(point, from: from) + let localPoint = visibleView.convert(point, from: nil) if visibleView.point(inside: localPoint, with: nil) { return visibleView.item }