Files

268 lines
9.6 KiB
Swift

// 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<Page: View>: 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<AnyCancellable>()
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
}
}