// Copyright (c) 2025 Proton Technologies AG // // This file is part of Proton Mail. // // Proton Mail is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Proton Mail is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Proton Mail. If not, see https://www.gnu.org/licenses/. import Combine import InboxDesignSystem import SwiftUI import proton_app_uniffi public struct PageViewController: UIViewControllerRepresentable { @Environment(\.presentationMode) var presentationMode let cursor: MailboxCursorProtocol? let isSwipeToAdjacentEnabled: Bool let scrollClipDisabled: Bool let startingPage: () -> Page let pageFactory: (CursorEntry) -> Page public init( cursor: MailboxCursorProtocol?, isSwipeToAdjacentEnabled: Bool, scrollClipDisabled: Bool = false, startingPage: @escaping () -> Page, pageFactory: @escaping (CursorEntry) -> Page ) { self.cursor = cursor self.isSwipeToAdjacentEnabled = isSwipeToAdjacentEnabled self.scrollClipDisabled = scrollClipDisabled self.startingPage = startingPage self.pageFactory = pageFactory } public func makeUIViewController(context: Context) -> UIPageViewController { let pageViewController: UIPageViewController if #available(iOS 18.0, *) { pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal) } else { pageViewController = LifecycleForwardingPageViewController( transitionStyle: .scroll, navigationOrientation: .horizontal ) } pageViewController.delegate = context.coordinator pageViewController.view.backgroundColor = DS.Color.Background.secondary.toDynamicUIColor let page = startingPage() let hostingController = UIHostingController(rootView: page) pageViewController.setViewControllers([hostingController], direction: .forward, animated: false) if scrollClipDisabled { pageViewController.view.clipsToBounds = false pageViewController.view.subviews .compactMap { $0 as? UIScrollView } .forEach { $0.clipsToBounds = false } } if let notifier = context.environment.goToNextPageNotifier { context.coordinator.subscribe(to: notifier, pageViewController: pageViewController) } return pageViewController } public func updateUIViewController(_ uiViewController: UIPageViewController, context: Context) { uiViewController.dataSource = isSwipeToAdjacentEnabled ? context.coordinator : nil context.coordinator.setCursor(cursor) } public func makeCoordinator() -> Coordinator { .init( pageFactory: pageFactory, dismiss: { presentationMode.wrappedValue.dismiss() } ) } } extension PageViewController { public final class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { private let pageFactory: (CursorEntry) -> Page private let dismiss: () -> Void private var cursor: MailboxCursorProtocol = EmptyCursor() private var cancellables = Set() init( pageFactory: @escaping (CursorEntry) -> Page, dismiss: @escaping () -> Void ) { self.pageFactory = pageFactory self.dismiss = dismiss } func setCursor(_ cursor: MailboxCursorProtocol?) { self.cursor = cursor ?? EmptyCursor() } func subscribe(to notifier: GoToNextPageNotifier, pageViewController: UIPageViewController) { notifier .publisher .sink { [weak self] _ in self?.goToNextPage(pageViewController: pageViewController) } .store(in: &cancellables) } private func goToNextPage(pageViewController: UIPageViewController) { guard let currentViewController = pageViewController.viewControllers?.first, let newCenterViewController = self.pageViewController(pageViewController, viewControllerAfter: currentViewController) else { dismiss() return } cursor.gotoNext() pageViewController.setViewControllers([newCenterViewController], direction: .forward, animated: false) } // MARK: UIPageViewControllerDataSource public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { makeViewController(adjacentTo: viewController, inDirection: .reverse) } public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { makeViewController(adjacentTo: viewController, inDirection: .forward) } private func makeViewController( adjacentTo centerViewController: UIViewController, inDirection direction: UIPageViewController.NavigationDirection ) -> UIViewController? { guard let adjacentView = adjacentView(direction: direction) else { return nil } // this is needed to be able to determine direction in didFinishAnimating switch direction { case .forward: adjacentView.view.tag = centerViewController.view.tag + 1 case .reverse: adjacentView.view.tag = centerViewController.view.tag - 1 @unknown default: break } return adjacentView } private func adjacentView(direction: UIPageViewController.NavigationDirection) -> UIViewController? { let result = adjacentItem(direction: direction) switch result { case .some(let adjacentItem): let page = pageFactory(adjacentItem) return UIHostingController(rootView: page) case .none: return nil case .unknown: let loadingView = LoadingView(dismiss: dismiss) { try await self.loadNextPage() } return UIHostingController(rootView: loadingView) } } private func adjacentItem(direction: UIPageViewController.NavigationDirection) -> MailboxCursorPeekNextResult { switch direction { case .forward: cursor.peekNext() case .reverse: if let previousItem = cursor.peekPrev() { .some(previousItem) } else { .none } @unknown default: .none } } private func loadNextPage() async throws -> Page { if let nextItem = try await cursor.fetchNext() { pageFactory(nextItem) } else { throw CursorError.nextPagePromisedButNotProvided } } // MARK: UIPageViewControllerDelegate public func pageViewController( _ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool ) { if completed { let reachedViewController = pageViewController.viewControllers![0] let previousViewController = previousViewControllers[0] if reachedViewController.view.tag > previousViewController.view.tag { cursor.gotoNext() } else if reachedViewController.view.tag < previousViewController.view.tag { cursor.gotoPrev() } } } } } /// On iOS 17, `UIPageViewController` fails to deliver `viewDidAppear(_:)` /// to its children it's shown for the first time. This breaks lifecycle-dependent logic. /// /// This subclass works around the issue by manually triggering the appearance /// transition for each child when the page view controller itself appears. /// Calling `beginAppearanceTransition(_:animated:)` followed by /// `endAppearanceTransition()` forces UIKit to correctly invoke the missing /// lifecycle callbacks (`viewWillAppear`, `viewDidAppear`) on the children. private class LifecycleForwardingPageViewController: UIPageViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) for viewController in children { viewController.beginAppearanceTransition(true, animated: animated) viewController.endAppearanceTransition() } } } private enum CursorError: Error { case nextPagePromisedButNotProvided } private final class EmptyCursor: MailboxCursorProtocol { func fetchNext() async throws(MailScrollerError) -> CursorEntry? { nil } func gotoNext() { } func gotoPrev() { } func peekNext() -> MailboxCursorPeekNextResult { .none } func peekPrev() -> CursorEntry? { nil } }