mirror of
https://github.com/ProtonMail/ios-mail.git
synced 2026-06-14 09:54:45 +00:00
268 lines
9.6 KiB
Swift
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
|
|
}
|
|
}
|