diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml
index cf0e181..e532dd4 100644
--- a/.github/workflows/swift.yml
+++ b/.github/workflows/swift.yml
@@ -39,4 +39,4 @@ jobs:
- name: Build Example
run: |
cd Example
- set -o pipefail && xcodebuild build -scheme 'Example' -destination 'platform=iOS Simulator,name=iPhone 16' | xcbeautify
+ set -o pipefail && xcodebuild build -scheme 'Example' -destination 'generic/platform=iOS Simulator' | xcbeautify
diff --git a/Example/AppDelegate.swift b/Example/AppDelegate.swift
index 401e913..6d2d823 100644
--- a/Example/AppDelegate.swift
+++ b/Example/AppDelegate.swift
@@ -52,3 +52,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
+
+/// Required on iOS 27, which no longer launches apps without scene support.
+/// The window and its root view controller come from `Main.storyboard`.
+class SceneDelegate: UIResponder, UIWindowSceneDelegate {
+
+ var window: UIWindow?
+
+}
diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj
index 3991ec4..7ce141d 100644
--- a/Example/Example.xcodeproj/project.pbxproj
+++ b/Example/Example.xcodeproj/project.pbxproj
@@ -361,7 +361,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -416,7 +416,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
diff --git a/Example/ExampleCardViewController.swift b/Example/ExampleCardViewController.swift
index fd19db6..6711810 100644
--- a/Example/ExampleCardViewController.swift
+++ b/Example/ExampleCardViewController.swift
@@ -28,6 +28,12 @@ class ExampleCardViewController: TGCardViewController {
navigationButtonsAreSpringLoaded = true
+ // Pass `-classicCards` as a launch argument to compare with the classic
+ // presentation, where the cards aren't in a system sheet.
+ if ProcessInfo.processInfo.arguments.contains("-classicCards") {
+ presentationStyle = .classic
+ }
+
#if targetEnvironment(macCatalyst)
mode = .sidebar
#endif
diff --git a/Example/Supporting Files/Info.plist b/Example/Supporting Files/Info.plist
index 9bd6904..4bf384e 100644
--- a/Example/Supporting Files/Info.plist
+++ b/Example/Supporting Files/Info.plist
@@ -22,6 +22,25 @@
Find yourself on the map
NSPrincipalClass
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneConfigurationName
+ Default Configuration
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).SceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
UILaunchStoryboardName
LaunchScreen
UIMainStoryboardFile
diff --git a/Example/cards/ExampleCityCard.swift b/Example/cards/ExampleCityCard.swift
index 6d87d8c..dd3c5f0 100644
--- a/Example/cards/ExampleCityCard.swift
+++ b/Example/cards/ExampleCityCard.swift
@@ -8,6 +8,7 @@
import Foundation
import MapKit
+import UIKit
import TGCardViewController
@@ -42,6 +43,32 @@ class ExampleCityCard : TGPlainCard {
init(city: City) {
self.city = city
super.init(title: .default(city.title, nil, nil), mapManager: city.manager)
+ updateBarActions()
+ }
+
+ private var isFavorite = false
+
+ /// Only shown next to a vertical bar, e.g., on the iPhone Duo. A real card
+ /// would show them itself when `showsVerticalBarActions` is `false`.
+ private func updateBarActions() {
+ verticalBarActions = [
+ UIAction(
+ title: isFavorite ? "Remove Favourite" : "Add Favourite",
+ image: UIImage(systemName: isFavorite ? "star.fill" : "star")
+ ) { [weak self] _ in
+ self?.isFavorite.toggle()
+ self?.updateBarActions()
+ },
+ UIAction(
+ title: "Share",
+ image: UIImage(systemName: "square.and.arrow.up")
+ ) { [weak self] action in
+ guard let self else { return }
+ let activity = UIActivityViewController(activityItems: [city.title], applicationActivities: nil)
+ activity.popoverPresentationController?.sourceItem = action.sender as? UIPopoverPresentationControllerSourceItem
+ controller?.present(activity, animated: true)
+ },
+ ]
}
}
diff --git a/Package.swift b/Package.swift
index 7fc52a0..0c9f44f 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,4 +1,4 @@
-// swift-tools-version:5.3
+// swift-tools-version:5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
@@ -7,7 +7,7 @@ let package = Package(
name: "TGCardViewController",
defaultLocalization: "en",
platforms: [
- .iOS(.v13)
+ .iOS(.v16)
],
products: [
.library(
diff --git a/README.md b/README.md
index da50983..afd85c9 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,35 @@ Provides a card-based view controller for mapping applications where the card's
}
```
+### System sheets on iOS 27+
+
+By default (`presentationStyle = .automatic`), on iOS 27 and later the cards are shown in a system sheet (`UISheetPresentationController`). The sheet adapts to the size classes by itself, like Maps':
+
+| | Presentation |
+|---|---|
+| Compact width, e.g., iPhone in portrait | Sheet at the bottom, full width |
+| Otherwise, e.g., iPad or iPhone in landscape | Sheet on the leading edge, 400pt wide |
+| Mac Catalyst, visionOS, `mode = .sidebar` | Classic |
+| The card controller is itself presented, but not full screen | Classic |
+| iOS 26 and earlier, or built with Xcode 26 and earlier | Classic |
+
+Set `presentationStyle` to `.classic` to opt out. Check `usesSystemSheet` for what's currently in use.
+
+Cards, map managers, headers, map buttons and paging work the same in both. Differences with a system sheet:
+
+- The map isn't dimmed and stays interactive when the card is extended.
+- The sheet provides the grab handle, the material, the corners and the dragging.
+- When a header is showing, the extended card stops below it.
+- Sheets presented by cards (e.g., with detents) stack on top of the card's sheet.
+
+Because the sheet is the card controller's `presentedViewController`:
+
+- `present(_:animated:completion:)` and `dismiss(animated:completion:)` on the card controller present on top of the sheet, and dismiss what's on top of it, but never the sheet itself.
+- Use `presentedOverlayViewController` to check what's presented on top of the cards, instead of `presentedViewController`.
+- Use `cardOverlayView` instead of `view` for views that you constrain to views inside a card, e.g., tooltips.
+
+On devices with a vertical bar, such as the iPhone Duo's outer display (iOS 27.1+), the system puts a sheet's bar items into that bar. The card controller does the same for cards, like Mail does: the top card's close button moves to the top of the bar, followed by buttons for the previous and next page of a `TGPageCard`, and the card's `verticalBarActions` go to the bottom of the bar as icon-only buttons. They stay in the bar at every height of the sheet, and the cards stay clear of it. Cards can observe `showsVerticalBarActions` to leave those actions out of their own content while they're in the bar.
+
## Specs
### 1. Basic functionality of cards
diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift
new file mode 100644
index 0000000..36917d5
--- /dev/null
+++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift
@@ -0,0 +1,1344 @@
+//
+// TGCardViewController+SystemSheet.swift
+// TGCardViewController
+//
+// Created by Adrian Schoenig on 9/10/2026.
+// Copyright © 2026 SkedGo Pty Ltd. All rights reserved.
+//
+
+import UIKit
+
+extension TGCardViewController {
+
+ /// How a ``TGCardViewController`` presents its cards.
+ public enum PresentationStyle {
+
+ /// Shows the cards in a system sheet (`UISheetPresentationController`) on
+ /// iOS 27 and later, and uses ``classic`` otherwise.
+ ///
+ /// The sheet adapts to the size classes by itself: at the bottom in compact
+ /// width, e.g., an iPhone in portrait, and on the leading edge otherwise,
+ /// e.g., on iPad or an iPhone in landscape, like Maps.
+ ///
+ /// Sheets aren't used in ``Mode-swift.enum/sidebar`` mode, on Mac Catalyst
+ /// or visionOS, or when the card controller is itself presented other than
+ /// full screen.
+ case automatic
+
+ /// The card controller positions the cards itself, and they're dragged using
+ /// its own gestures.
+ case classic
+ }
+
+ /// Whether the cards are currently shown in a system sheet.
+ ///
+ /// See ``presentationStyle``. Doesn't change with the size classes, but the
+ /// sheet comes and goes with the cards, and while the controller is hidden.
+ public var usesSystemSheet: Bool {
+ sheetHost != nil
+ }
+
+ /// The view controller that's presented on top of the cards, if any.
+ ///
+ /// Use this instead of `presentedViewController` to check if something covers
+ /// the cards: when the cards are shown in a system sheet, the sheet itself is
+ /// the `presentedViewController` of this controller.
+ public var presentedOverlayViewController: UIViewController? {
+ if let sheetHost, presentedViewController === sheetHost {
+ return sheetHost.presentedViewController
+ } else {
+ return presentedViewController
+ }
+ }
+
+ /// The view that contains the cards' views.
+ ///
+ /// Use this if you need to add views that are positioned relative to views of a
+ /// card. When the cards are shown in a system sheet, this is the sheet's view;
+ /// otherwise it's this controller's `view`.
+ public var cardOverlayView: UIView {
+ sheetHost?.view ?? view
+ }
+
+}
+
+// MARK: - Deciding when to use a sheet
+
+extension TGCardViewController {
+
+ /// Whether the cards should be in a system sheet. Doesn't depend on the size
+ /// classes, as the sheet adapts to those by itself.
+ var wantsSystemSheet: Bool {
+#if compiler(>=6.4) && os(iOS) && !targetEnvironment(macCatalyst) // Xcode 27 proxy, for the sheet placement API
+ guard
+ #available(iOS 27.0, *),
+ presentationStyle == .automatic,
+ mode == .floating
+ else { return false }
+
+ // Don't show a sheet from a sheet. Being presented full screen is fine.
+ let outermost = sequence(first: self as UIViewController, next: \.parent).reduce(self as UIViewController) { $1 }
+ if outermost.presentingViewController != nil,
+ ![.fullScreen, .overFullScreen].contains(outermost.modalPresentationStyle) {
+ return false
+ }
+
+ return true
+#else
+ return false
+#endif
+ }
+
+ /// The sheet's width where it's not full width, like Maps'
+ static let sheetPreferredWidth: CGFloat = 400
+
+ /// Sets up the sheet once, so that UIKit adapts it to the size class: full
+ /// width at the bottom in compact width, and narrower on the leading edge
+ /// otherwise, e.g., on iPad and phones in landscape, like Maps.
+ ///
+ /// - Note: Needs to be called before presenting.
+ private func configureSheetSizing(_ host: TGSheetHostViewController) {
+#if compiler(>=6.4) && os(iOS) && !targetEnvironment(macCatalyst)
+ guard #available(iOS 27.0, *), let sheet = host.sheetPresentationController else { return }
+ sheet.preferredPlacement = .leading
+ // The width follows the preferred content size, when floating (rather
+ // than following the readable width) and when attached to an edge. Both
+ // are ignored in compact width, where the sheet spans the full width.
+ sheet.prefersPageSizing = false
+ sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true
+ // The height doesn't matter, as the detents decide it, but needs to be set,
+ // as UIKit ignores a preferred content size without one
+ host.preferredContentSize = CGSize(width: Self.sheetPreferredWidth, height: 1_000)
+#endif
+ }
+
+ /// Installs or removes the system sheet, as appropriate for the current state.
+ ///
+ /// Safe to call often: it does nothing if the sheet is already in the right state,
+ /// and it defers changes it can't make yet, e.g., while something is presented.
+ func updateSystemSheetPresentation() {
+ guard isViewLoaded else { return }
+
+ let wantsSheet = wantsSystemSheet && topCardView != nil
+ if wantsSheet, sheetHost == nil {
+ installSystemSheet()
+ } else if !wantsSheet, sheetHost != nil {
+ uninstallSystemSheet(animated: topCardView == nil && isVisible)
+ } else if !wantsSheet, isAwaitingSystemSheet {
+ revealClassicCards()
+ }
+ }
+
+ private func revealClassicCards() {
+ guard isAwaitingSystemSheet else { return }
+ isAwaitingSystemSheet = false
+ toggleCardWrappers(hide: topCardView == nil)
+ }
+
+}
+
+// MARK: - Installing and removing the sheet
+
+extension TGCardViewController {
+
+ private func installSystemSheet() {
+ guard
+ isVisible,
+ view.window != nil,
+ presentedViewController == nil,
+ transitionCoordinator == nil,
+ let content = cardWrapperContent
+ else {
+ // We'll try again later, e.g., after the presented view controller got
+ // dismissed. Until then, show the cards in the classic way, as we don't
+ // know when that'll be.
+ revealClassicCards()
+ return
+ }
+
+ let position = cardPosition
+
+ let host = TGSheetHostViewController(cardController: self)
+ host.loadViewIfNeeded()
+ syncSheetHostAppearance(host)
+
+ // 1. Move the cards into the sheet. The card wrapper stays where it is, but
+ // invisible, and it'll mirror the top edge of the sheet. That way all the
+ // map buttons, the header and the map's insets keep working as before.
+ savedCardContentConstraints = cardWrapperShadow.constraints.filter {
+ $0.firstItem === content || $0.secondItem === content
+ }
+ content.removeFromSuperview()
+ host.view.addSubview(content)
+ content.translatesAutoresizingMaskIntoConstraints = false
+
+ // The cards keep the height they'd have when extended, and the sheet clips
+ // them, rather than relaying them out whenever the sheet changes its height.
+ // Horizontally, they stay within the safe area, and clear of the column
+ // of a vertical bar, e.g., on an iPhone Duo; see `syncVerticalBarItems`.
+ let heightConstraint = content.heightAnchor.constraint(equalToConstant: estimatedSheetContentHeight(in: host))
+ let leftConstraint = content.leftAnchor.constraint(equalTo: host.view.safeAreaLayoutGuide.leftAnchor)
+ let rightConstraint = host.view.safeAreaLayoutGuide.rightAnchor.constraint(equalTo: content.rightAnchor)
+ NSLayoutConstraint.activate([
+ content.topAnchor.constraint(equalTo: host.view.topAnchor),
+ leftConstraint,
+ rightConstraint,
+ heightConstraint,
+ ])
+ sheetContentHeightConstraint = heightConstraint
+ sheetContentLeftConstraint = leftConstraint
+ sheetContentRightConstraint = rightConstraint
+
+ // 2. Configure the sheet
+ sheetHost = host
+ isAwaitingSystemSheet = false
+
+#if !os(visionOS) // No detents on visionOS, where cards never use a sheet
+ if let sheet = host.sheetPresentationController {
+ sheet.delegate = host
+ sheet.prefersScrollingExpandsWhenScrolledToEdge = true
+ sheet.prefersEdgeAttachedInCompactHeight = true
+ sheetTargetPosition = position
+ applySheetConfiguration(to: sheet, selecting: position)
+ }
+#endif
+
+ // 3. Hand over the chrome
+ updateCardChromeForPresentationStyle()
+ updateForNewPosition(position: position)
+ updateSheetContentScrollView()
+
+ configureSheetSizing(host)
+ super.present(host, animated: false)
+ }
+
+ /// Moves the cards back into this controller's view and removes the sheet.
+ ///
+ /// If something's presented on top of the sheet, this does nothing. Call
+ /// ``updateSystemSheetPresentation()`` again once that's gone.
+ func uninstallSystemSheet(animated: Bool) {
+ guard let host = sheetHost, host.presentedViewController == nil else { return }
+
+ let position = cardPosition
+ sheetHost = nil
+ sheetBackgroundView = nil
+ headerSheetBackgroundView?.removeFromSuperview()
+ headerSheetBackgroundView = nil
+ sheetTargetPosition = nil
+ sheetDetentValues = [:]
+ updateVerticalBarItems()
+
+ let moveCardsBack = { [self] in
+ guard let content = cardWrapperContent else { return assertionFailure() }
+
+ content.removeFromSuperview()
+ cardWrapperShadow.insertSubview(content, aboveSubview: cardWrapperEffectView)
+ NSLayoutConstraint.activate(savedCardContentConstraints)
+ savedCardContentConstraints = []
+ sheetContentHeightConstraint = nil
+ sheetContentLeftConstraint = nil
+ sheetContentRightConstraint = nil
+
+ updateCardChromeForPresentationStyle()
+ }
+
+ host.allowsDismissingSheet = true
+ if host.isBeingDismissed {
+ // E.g., when we're dismissed ourselves, which takes the sheet along
+ moveCardsBack()
+ } else if animated {
+ super.dismiss(animated: true, completion: moveCardsBack)
+ } else {
+ moveCardsBack()
+ super.dismiss(animated: false)
+ }
+
+ // Put the card where the sheet was
+ let location = cardLocation(forDesired: position, direction: .up)
+ mapViewController.additionalSafeAreaInsets = updateCardPosition(y: location.y)
+ view.setNeedsUpdateConstraints()
+ updateCardScrolling(allow: location.position == .extended, view: topCardView)
+ updateMapShadow(for: location.position)
+ updateForNewPosition(position: location.position)
+ }
+
+ /// Gestures, effects, grab handles and corners differ between the classic
+ /// presentation and the sheet.
+ private func updateCardChromeForPresentationStyle() {
+ let inSheet = usesSystemSheet
+
+ // The sheet provides the material, so the card wrapper is a placeholder
+ if inSheet {
+ if savedCardWrapperEffect == nil {
+ savedCardWrapperEffect = cardWrapperEffectView.effect
+ }
+ cardWrapperEffectView.effect = nil
+ } else if let effect = savedCardWrapperEffect {
+ cardWrapperEffectView.effect = effect
+ savedCardWrapperEffect = nil
+ }
+ cardWrapperShadow.isUserInteractionEnabled = !inSheet
+ toggleCardWrappers(hide: topCardView == nil)
+
+ // The sheet does the dragging
+ mapShadowTapper.isEnabled = !inSheet && mode == .floating
+ if inSheet {
+ panner.isEnabled = false
+ } else {
+ updatePannerInteractivity()
+ if !isSheetDraggingEnabled {
+ panner.isEnabled = false
+ }
+ }
+
+#if !os(visionOS)
+ // Popping by swiping from the edge should also work on the sheet, not just
+ // on the map. The sheet's recognizer goes away with the sheet.
+ if let sheetView = sheetHost?.view, !(sheetView.gestureRecognizers ?? []).contains(where: { $0 is UIScreenEdgePanGestureRecognizer }) {
+ let sheetEdgePanner = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(popMaybe))
+ sheetEdgePanner.edges = edgePanner.edges
+ sheetEdgePanner.isEnabled = edgePanner.isEnabled
+ sheetView.addGestureRecognizer(sheetEdgePanner)
+ }
+#endif
+
+ updateMapShadow(for: inSheet ? .collapsed : cardPosition)
+ updateGrabHandleVisibility()
+ updateCardScrolling(allow: inSheet || cardPosition == .extended, view: topCardView)
+
+ for cardView in cards.compactMap(\.view) {
+ applyPresentationStyle(to: cardView)
+ cardView.adjustContentAlpha(to: contentAlpha(for: cardPosition))
+ }
+
+ // Card-attached floating views need to be next to the card
+ if cardFloatingView.superview != nil {
+ cardFloatingView.removeFromSuperview()
+ updateCardFloatingViewContent(card: topCard)
+ }
+
+ view.setNeedsLayout()
+ }
+
+ func applyPresentationStyle(to cardView: TGCardView) {
+ guard #available(iOS 26.0, visionOS 26.0, *) else { return }
+
+ if usesSystemSheet {
+ // The sheet rounds and clips its content
+ cardView.cornerConfiguration = .corners(radius: .fixed(0))
+ cardView.clipsToBounds = false
+ } else if mode == .floating {
+ // Match the corners of the glass behind the card, so that cards with a
+ // non-clear background don't stick out at the corners
+ cardView.cornerConfiguration = cardWrapperEffectView.cornerConfiguration
+ cardView.clipsToBounds = true
+ }
+ }
+
+ /// The classic card hides everything but its title when collapsed, as the rest
+ /// would show below the screen's safe area. The sheet clips that itself.
+ func contentAlpha(for position: TGCardPosition) -> CGFloat {
+ position != .collapsed ? 1 : 0
+ }
+
+ func forcesSeparatorHidden(for position: TGCardPosition) -> Bool {
+ !usesSystemSheet && position == .collapsed
+ }
+
+ /// The view that card transitions add their temporary views to
+ var cardTransitionContainer: UIView? {
+ usesSystemSheet ? cardWrapperContent.superview : cardWrapperShadow.superview
+ }
+
+ /// The view that card transitions add their temporary views next to
+ var cardTransitionAnchor: UIView {
+ usesSystemSheet ? cardWrapperContent : cardWrapperShadow
+ }
+
+ private func syncSheetHostAppearance(_ host: TGSheetHostViewController) {
+ if host.view.tintColor != view.tintColor {
+ host.view.tintColor = view.tintColor
+ }
+ if host.overrideUserInterfaceStyle != overrideUserInterfaceStyle {
+ host.overrideUserInterfaceStyle = overrideUserInterfaceStyle
+ }
+ }
+
+ /// Tells the sheet which scroll view to track for scrolling vs. resizing.
+ func updateSheetContentScrollView() {
+ sheetHost?.setContentScrollView(topCardView?.contentScrollView)
+ updateVerticalBarItems()
+ }
+
+}
+
+// MARK: - Bar items next to a vertical bar
+
+extension TGCardViewController {
+
+ /// On devices with a vertical bar, e.g., the iPhone Duo's outer display, the
+ /// system puts a sheet's close button and its navigation bar's items into
+ /// that bar. Cards have their close buttons in their titles, which end up
+ /// under the status bar when the sheet is at full height. So while the cards
+ /// are in a system sheet next to a vertical bar, this hides the close buttons
+ /// in the top card's titles, and shows a stand-in in the bar, below the
+ /// status bar, which forwards taps to the card's own. The top card's
+ /// `verticalBarActions` follow below it.
+ ///
+ /// The items stay in the bar at every height of the sheet. One copy of them
+ /// is over the map, and another one on the sheet, where the sheet covers the
+ /// bar; see `syncVerticalBarItems(sheetTop:)`.
+ func updateVerticalBarItems() {
+#if compiler(>=6.4) && os(iOS) && !targetEnvironment(macCatalyst) // Xcode 27 proxy; vertical bars are iPhone and iPad only
+ guard #available(iOS 27.1, *) else { return }
+
+ let barEdge = sheetHost?.traitCollection.verticalBarEdge ?? .unspecified
+ let showsBar = sheetHost != nil && barEdge != .unspecified
+ updateShowsVerticalBarActions(showsBar)
+
+ let closeButtons: [UIButton]
+ let currentCloseButton: UIButton?
+ let actions: [UIAction]
+ let paging: TGVerticalBarItemsView.Paging?
+ if let pageCard = topCard as? TGPageCard {
+ // Hide them on all pages, so they don't show up while paging
+ closeButtons = pageCard.cards.compactMap { $0.cardView?.dismissButton }
+ currentCloseButton = pageCard.currentCard.cardView?.dismissButton
+ actions = pageCard.verticalBarActions + pageCard.currentCard.verticalBarActions
+ let index = pageCard.currentPageIndex
+ paging = pageCard.cards.count > 1
+ ? .init(hasPrevious: index > 0, hasNext: index < pageCard.cards.count - 1)
+ : nil
+ } else {
+ currentCloseButton = topCardView?.dismissButton
+ closeButtons = [currentCloseButton].compactMap { $0 }
+ actions = topCard?.verticalBarActions ?? []
+ paging = nil
+ }
+
+ guard
+ showsBar,
+ let host = sheetHost,
+ currentCloseButton != nil || paging != nil || !actions.isEmpty
+ else {
+ suppressedCloseButtons.forEach { Self.setCloseButton($0, suppressed: false) }
+ suppressedCloseButtons = []
+ if let host = sheetHost {
+ updateLayoutForVerticalBar(sheetTop: host.view.convert(host.view.bounds, to: view).minY)
+ } else {
+ letBottomFloatingViewIgnoreVerticalBar(false)
+ }
+ mapVerticalBarItems?.removeFromSuperview()
+ mapVerticalBarItems = nil
+ sheetVerticalBarItems?.removeFromSuperview()
+ sheetVerticalBarItems = nil
+ verticalBarItemsEdge = nil
+ return
+ }
+
+ // Swap which buttons are hidden in the titles
+ for button in suppressedCloseButtons where !closeButtons.contains(button) {
+ Self.setCloseButton(button, suppressed: false)
+ }
+ closeButtons.forEach { Self.setCloseButton($0, suppressed: true) }
+ suppressedCloseButtons = closeButtons
+
+ let edge: NSDirectionalRectEdge = barEdge == .leading ? .leading : .trailing
+ let mapItems: TGVerticalBarItemsView
+ let sheetItems: TGVerticalBarItemsView
+ if let existingMap = mapVerticalBarItems, let existingSheet = sheetVerticalBarItems, verticalBarItemsEdge == edge, existingMap.superview === view, existingSheet.superview === host.view {
+ mapItems = existingMap
+ sheetItems = existingSheet
+ } else {
+ mapVerticalBarItems?.removeFromSuperview()
+ sheetVerticalBarItems?.removeFromSuperview()
+
+ // In the bar along the edge of the screen, below the status bar
+ mapItems = makeBarItemsView()
+ mapItems.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(mapItems)
+ let guide = view.layoutGuide(for: .bar(onEdge: edge, extent: TGVerticalBarItemsView.itemSize))
+ NSLayoutConstraint.activate([
+ mapItems.topAnchor.constraint(equalTo: guide.topAnchor),
+ mapItems.bottomAnchor.constraint(equalTo: guide.bottomAnchor),
+ mapItems.centerXAnchor.constraint(equalTo: guide.centerXAnchor),
+ mapItems.widthAnchor.constraint(equalToConstant: TGVerticalBarItemsView.itemSize),
+ ])
+
+ // Positioned to match `mapItems` whenever the sheet lays out
+ sheetItems = makeBarItemsView()
+ host.view.addSubview(sheetItems)
+
+ mapVerticalBarItems = mapItems
+ sheetVerticalBarItems = sheetItems
+ verticalBarItemsEdge = edge
+ }
+
+ let style = topCard?.style ?? .default
+ mapItems.update(closeButtonLike: currentCloseButton, style: style, paging: paging, actions: actions)
+ sheetItems.update(closeButtonLike: currentCloseButton, style: style, paging: paging, actions: actions)
+ view.bringSubviewToFront(mapItems)
+ host.view.bringSubviewToFront(sheetItems)
+
+ view.layoutIfNeeded()
+ syncVerticalBarItems(sheetTop: host.view.convert(host.view.bounds, to: view).minY)
+#endif
+ }
+
+ /// Puts the sheet's copy of the bar items where the map's are, and shows
+ /// each item on whichever of the two is in front of the bar at its position:
+ /// the map above the sheet's top, the sheet below it. The sheet's copy gets
+ /// clipped by the sheet. Called whenever the sheet lays out, including while
+ /// it's being dragged.
+ ///
+ /// - Parameter sheetTop: The top of the sheet in this controller's view
+ func syncVerticalBarItems(sheetTop: CGFloat) {
+ updateLayoutForVerticalBar(sheetTop: sheetTop)
+
+ guard
+ let mapItems = mapVerticalBarItems,
+ let sheetItems = sheetVerticalBarItems,
+ let host = sheetHost
+ else { return }
+
+ let frame = host.view.convert(mapItems.frame, from: view)
+ if sheetItems.frame != frame {
+ sheetItems.frame = frame
+ }
+ sheetItems.layoutIfNeeded()
+
+ // When collapsed, the card's actions aren't shown in the bar
+ let expansion = sheetExpansion(forSheetTop: sheetTop) ?? 1
+ let isCollapsed = expansion < 0.01
+
+ // Groups fade as a whole, so they don't get cut in half
+ for (mapGroup, sheetGroup) in zip(mapItems.groups, sheetItems.groups) {
+ let groupFrame = mapGroup.convert(mapGroup.bounds, to: view)
+ mapGroup.alpha = groupFrame.minY < sheetTop ? 1 : 0
+ sheetGroup.alpha = groupFrame.maxY > sheetTop ? 1 : 0
+ }
+
+ // Buttons are tappable where they're mostly visible
+ for (mapButton, sheetButton) in zip(mapItems.buttons, sheetItems.buttons) {
+ let buttonFrame = mapButton.convert(mapButton.bounds, to: view)
+ let mapOwnsButton = buttonFrame.midY < sheetTop
+ mapButton.isUserInteractionEnabled = mapOwnsButton
+ mapButton.accessibilityElementsHidden = !mapOwnsButton
+ sheetButton.isUserInteractionEnabled = !mapOwnsButton
+ sheetButton.accessibilityElementsHidden = mapOwnsButton
+ }
+
+ // The card's actions fade in along with its content
+ for items in [mapItems, sheetItems] {
+ let actions = items.actionsGroup
+ actions.alpha *= expansion
+ actions.isUserInteractionEnabled = expansion > 0.5
+ actions.accessibilityElementsHidden = isCollapsed
+ }
+ }
+
+ /// Keeps the cards and the bottom map buttons clear of a vertical bar, if
+ /// there's one, except when the sheet is collapsed, where they use the full
+ /// width. That's independent of whether the top card has anything to show
+ /// in the bar.
+ ///
+ /// - Parameter sheetTop: The top of the sheet in this controller's view
+ func updateLayoutForVerticalBar(sheetTop: CGFloat) {
+ guard let host = sheetHost else { return }
+
+ var column: CGRect? = nil
+#if compiler(>=6.4) && os(iOS) && !targetEnvironment(macCatalyst)
+ if #available(iOS 27.1, *), host.traitCollection.verticalBarEdge != .unspecified {
+ let edge: NSDirectionalRectEdge = host.traitCollection.verticalBarEdge == .leading ? .leading : .trailing
+ if verticalBarColumnGuide?.edge != edge {
+ verticalBarColumnGuide = (edge, view.layoutGuide(for: .bar(onEdge: edge, extent: TGVerticalBarItemsView.itemSize)))
+ }
+ if let guide = verticalBarColumnGuide?.guide {
+ column = host.view.convert(guide.layoutFrame, from: view)
+ }
+ }
+#endif
+
+ // When collapsed, the sheet only shows the card's title, which can then
+ // use the full width, as can the map buttons above it
+ let isCollapsed = (sheetExpansion(forSheetTop: sheetTop) ?? 1) < 0.01
+ keepSheetContentClear(ofBarColumn: column, in: host, isCollapsed: isCollapsed)
+ letBottomFloatingViewIgnoreVerticalBar(isCollapsed && column.map { !isBarOnLeadingEdge($0, in: host.view) } ?? false)
+ }
+
+ private func isBarOnLeadingEdge(_ column: CGRect, in hostView: UIView) -> Bool {
+ let isOnLeft = column.midX < hostView.bounds.midX
+ return hostView.effectiveUserInterfaceLayoutDirection == .rightToLeft ? !isOnLeft : isOnLeft
+ }
+
+ /// Lets the bottom map buttons move into the vertical bar's column, while
+ /// the sheet is collapsed next to a bar on the trailing edge, so that they
+ /// line up with the collapsed sheet below them
+ private func letBottomFloatingViewIgnoreVerticalBar(_ ignore: Bool) {
+ guard ignore != bottomFloatingViewIgnoresVerticalBar else { return }
+ bottomFloatingViewIgnoresVerticalBar = ignore
+ updateFloatingViewsConstraints()
+ UIView.animate(withDuration: Constants.tapAnimationDuration) {
+ self.view.layoutIfNeeded()
+ }
+ }
+
+ /// Lets the cards in the sheet span its full width, including under the
+ /// column of the vertical bar, while their contents stay clear of it: the
+ /// safe area on the bar's side covers the column, and cards keep their titles
+ /// and cell contents within the safe area. That way only backgrounds and the
+ /// scroll indicator extend under the bar, like in Apple's apps.
+ ///
+ /// The system's safe area covers that column for a bar on the trailing edge,
+ /// but not for one on the leading edge, e.g., on an unfolded iPhone Duo when
+ /// the app is on the left screen, so the sheet host's additional safe area
+ /// makes up the difference.
+ ///
+ /// - Parameters:
+ /// - column: The bar's items in `host`'s view's coordinates, or `nil` if
+ /// there's no vertical bar
+ /// - host: The sheet's view controller
+ /// - isCollapsed: Whether the sheet is collapsed, which shows only the
+ /// card's title, so it uses the full width, ignoring the bar
+ func keepSheetContentClear(ofBarColumn column: CGRect?, in host: TGSheetHostViewController, isCollapsed: Bool = false) {
+ let hostView = host.view!
+ let additional = host.additionalSafeAreaInsets
+ let systemLeft = hostView.safeAreaInsets.left - additional.left
+ let systemRight = hostView.safeAreaInsets.right - additional.right
+
+ var safeLeft = systemLeft
+ var safeRight = systemRight
+ var left: CGFloat = 0
+ var right: CGFloat = 0
+ if let column, !column.isEmpty {
+ if column.midX < hostView.bounds.midX {
+ safeLeft = isCollapsed ? 0 : max(systemLeft, column.maxX)
+ left = -safeLeft
+ } else {
+ safeRight = isCollapsed ? 0 : max(systemRight, hostView.bounds.width - column.minX)
+ right = -safeRight
+ }
+ }
+
+ var changed = false
+ var newAdditional = additional
+ newAdditional.left = safeLeft - systemLeft
+ newAdditional.right = safeRight - systemRight
+ if abs(newAdditional.left - additional.left) > 0.5 || abs(newAdditional.right - additional.right) > 0.5 {
+ host.additionalSafeAreaInsets = newAdditional
+ changed = true
+ }
+ if let constraint = sheetContentLeftConstraint, abs(constraint.constant - left) > 0.5 {
+ constraint.constant = left
+ changed = true
+ }
+ if let constraint = sheetContentRightConstraint, abs(constraint.constant - right) > 0.5 {
+ constraint.constant = right
+ changed = true
+ }
+
+ // This typically runs while the sheet lays out, after the cards in it
+ // have been laid out already, and the sheet's last layout pass when it
+ // settles doesn't necessarily get followed by another one. So make sure
+ // the cards pick up the change.
+ if changed {
+ DispatchQueue.main.async { [weak host] in
+ host?.view.setNeedsLayout()
+ host?.view.layoutIfNeeded()
+ }
+ }
+ }
+
+ private func makeBarItemsView() -> TGVerticalBarItemsView {
+ TGVerticalBarItemsView { [weak self] in
+ self?.forwardVerticalBarCloseTap()
+ } onPage: { [weak self] forward in
+ guard let pageCard = self?.topCard as? TGPageCard else { return }
+ if forward {
+ pageCard.moveForward()
+ } else {
+ pageCard.moveBackward()
+ }
+ }
+ }
+
+ /// Tells the cards in the stack whether vertical bar actions are shown, so
+ /// that they can leave them out of their content.
+ private func updateShowsVerticalBarActions(_ shows: Bool) {
+ for card in cards.map(\.card) {
+ let pages = (card as? TGPageCard)?.cards ?? []
+ for card in [card] + pages where card.showsVerticalBarActions != shows {
+ card.showsVerticalBarActions = shows
+ }
+ }
+ }
+
+ private func forwardVerticalBarCloseTap() {
+ let closeButton: UIButton?
+ if let pageCard = topCard as? TGPageCard {
+ closeButton = pageCard.currentCard.cardView?.dismissButton
+ } else {
+ closeButton = topCardView?.dismissButton
+ }
+ closeButton?.sendActions(for: .touchUpInside)
+ }
+
+ /// Hides a close button in a card's title, without changing its layout, nor
+ /// its `isHidden`, which says whether the card should have a close button.
+ private static func setCloseButton(_ button: UIButton, suppressed: Bool) {
+ button.alpha = suppressed ? 0 : 1
+ button.isUserInteractionEnabled = !suppressed
+ button.accessibilityElementsHidden = suppressed
+ }
+
+}
+
+/// A card's items in a vertical bar, like the system's for a navigation bar:
+/// the close button and, for paging cards, buttons for the previous and next
+/// page at the top, and the bar actions at the bottom, all icon-only.
+final class TGVerticalBarItemsView: UIView {
+
+ struct Paging {
+ var hasPrevious: Bool
+ var hasNext: Bool
+ }
+
+ /// The size of each item, and the width of the bar region they're centred
+ /// in, which matches the system's close buttons
+ static let itemSize: CGFloat = 44
+
+ /// The size that icons of vertical bar actions get scaled to fit in, unless
+ /// they're symbol images
+ private static let iconSize: CGFloat = 20
+
+ init(onClose: @escaping () -> Void, onPage: @escaping (_ forward: Bool) -> Void) {
+ closeButton = Self.makeButton()
+ closeSlot = UIView()
+ previousButton = Self.makeButton()
+ nextButton = Self.makeButton()
+ pagingGroup = Self.makeGroup(with: [previousButton, nextButton])
+ actionsStack = Self.makeStack()
+ actionsGroup = Self.makeGroup(with: actionsStack)
+ super.init(frame: .zero)
+
+ closeButton.addAction(UIAction { _ in onClose() }, for: .touchUpInside)
+ previousButton.addAction(UIAction { _ in onPage(false) }, for: .primaryActionTriggered)
+ nextButton.addAction(UIAction { _ in onPage(true) }, for: .primaryActionTriggered)
+ previousButton.accessibilityLabel = NSLocalizedString("Previous card", bundle: TGCardViewController.bundle, comment: "")
+ nextButton.accessibilityLabel = NSLocalizedString("Next card", bundle: TGCardViewController.bundle, comment: "")
+
+ // The slot keeps its space when a page has no close button, so that the
+ // paging buttons don't move around while paging
+ closeSlot.translatesAutoresizingMaskIntoConstraints = false
+ closeSlot.addSubview(closeButton)
+ NSLayoutConstraint.activate([
+ closeButton.topAnchor.constraint(equalTo: closeSlot.topAnchor),
+ closeButton.bottomAnchor.constraint(equalTo: closeSlot.bottomAnchor),
+ closeButton.leadingAnchor.constraint(equalTo: closeSlot.leadingAnchor),
+ closeButton.trailingAnchor.constraint(equalTo: closeSlot.trailingAnchor),
+ ])
+
+ let topStack = UIStackView(arrangedSubviews: [closeSlot, pagingGroup])
+ topStack.axis = .vertical
+ topStack.alignment = .center
+ topStack.spacing = 8
+ topStack.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(topStack)
+ addSubview(actionsGroup)
+
+ NSLayoutConstraint.activate([
+ topStack.topAnchor.constraint(equalTo: topAnchor),
+ topStack.centerXAnchor.constraint(equalTo: centerXAnchor),
+ actionsGroup.bottomAnchor.constraint(equalTo: bottomAnchor),
+ actionsGroup.centerXAnchor.constraint(equalTo: centerXAnchor),
+ ])
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ private let closeButton: UIButton
+ private let closeSlot: UIView
+ private let previousButton: UIButton
+ private let nextButton: UIButton
+ private let pagingGroup: UIView
+ private let actionsStack: UIStackView
+ /// The group with the card's actions, at the bottom
+ let actionsGroup: UIView
+ private var actionButtons: [UIButton] = []
+ private var actions: [UIAction] = []
+
+ /// The items that show or hide as a whole, from top to bottom
+ var groups: [UIView] {
+ [closeSlot, pagingGroup, actionsGroup]
+ }
+
+ /// All buttons from top to bottom, including hidden ones
+ var buttons: [UIButton] {
+ [closeButton, previousButton, nextButton] + actionButtons
+ }
+
+ /// Lets touches through, except on the items
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
+ let hit = super.hitTest(point, with: event)
+ return hit === self ? nil : hit
+ }
+
+ /// - Parameters:
+ /// - source: The card's close button that `closeButton` stands in for
+ /// - style: The card's style
+ /// - paging: Where a page card is at, if the card is paging
+ /// - actions: The vertical bar actions
+ @available(iOS 26.0, *)
+ func update(closeButtonLike source: UIButton?, style: TGCardStyle, paging: Paging?, actions: [UIAction]) {
+ TGCard.configureCloseButton(closeButton, style: style)
+ closeButton.accessibilityLabel = source?.accessibilityLabel
+ ?? NSLocalizedString("Close card", bundle: TGCardViewController.bundle, comment: "")
+ closeButton.isSpringLoaded = source?.isSpringLoaded ?? false
+ closeButton.isHidden = source?.isHidden ?? true
+ closeSlot.isHidden = closeButton.isHidden && paging == nil
+
+ pagingGroup.isHidden = paging == nil
+ Self.configure(previousButton, image: UIImage(systemName: "chevron.left"))
+ Self.configure(nextButton, image: UIImage(systemName: "chevron.right"))
+ previousButton.isEnabled = paging?.hasPrevious ?? false
+ nextButton.isEnabled = paging?.hasNext ?? false
+
+ // Reuse the action buttons, which perform whichever action is at their
+ // index, so that updating an action doesn't flicker.
+ self.actions = actions
+ actionsGroup.isHidden = actions.isEmpty
+ while actionButtons.count < actions.count {
+ let index = actionButtons.count
+ let button = Self.makeButton()
+ button.addAction(UIAction { [weak self, weak button] _ in
+ guard let self, let button, index < self.actions.count else { return }
+ button.sendAction(self.actions[index])
+ }, for: .primaryActionTriggered)
+ actionsStack.addArrangedSubview(button)
+ actionButtons.append(button)
+ }
+ while actionButtons.count > actions.count {
+ actionButtons.removeLast().removeFromSuperview()
+ }
+ for (button, action) in zip(actionButtons, actions) {
+ Self.configure(button, image: Self.icon(action.image), isDestructive: action.attributes.contains(.destructive))
+ button.accessibilityLabel = action.title
+ button.isEnabled = !action.attributes.contains(.disabled)
+ button.isSelected = action.state == .on
+ }
+ }
+
+ private static func makeButton() -> UIButton {
+ let button = UIButton(type: .system)
+ button.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ button.widthAnchor.constraint(equalToConstant: itemSize),
+ button.heightAnchor.constraint(equalToConstant: itemSize),
+ ])
+ return button
+ }
+
+ private static func makeStack(with buttons: [UIButton] = []) -> UIStackView {
+ let stack = UIStackView(arrangedSubviews: buttons)
+ stack.axis = .vertical
+ stack.alignment = .center
+ return stack
+ }
+
+ private static func makeGroup(with buttons: [UIButton]) -> UIView {
+ makeGroup(with: makeStack(with: buttons))
+ }
+
+ /// Buttons sharing one capsule, like the system groups bar items
+ private static func makeGroup(with stack: UIStackView) -> UIView {
+ let group: UIVisualEffectView
+ if #available(iOS 26.0, visionOS 26.0, *) {
+#if os(visionOS)
+ group = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
+#else
+ let glass = UIGlassEffect()
+ glass.isInteractive = true
+ group = UIVisualEffectView(effect: glass)
+#endif
+ group.cornerConfiguration = .capsule()
+ } else {
+ group = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
+ group.layer.cornerRadius = itemSize / 2
+ group.clipsToBounds = true
+ }
+ group.translatesAutoresizingMaskIntoConstraints = false
+
+ stack.translatesAutoresizingMaskIntoConstraints = false
+ group.contentView.addSubview(stack)
+ NSLayoutConstraint.activate([
+ stack.topAnchor.constraint(equalTo: group.contentView.topAnchor),
+ stack.bottomAnchor.constraint(equalTo: group.contentView.bottomAnchor),
+ stack.leadingAnchor.constraint(equalTo: group.contentView.leadingAnchor),
+ stack.trailingAnchor.constraint(equalTo: group.contentView.trailingAnchor),
+ group.widthAnchor.constraint(equalToConstant: itemSize),
+ ])
+ return group
+ }
+
+ /// Icon-only and monochrome, matching the close button, but on the group's
+ /// capsule rather than one of its own
+ private static func configure(_ button: UIButton, image: UIImage?, isDestructive: Bool = false) {
+ var config = UIButton.Configuration.plain()
+ config.image = image
+ config.imagePlacement = .all
+ config.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .medium)
+ config.imagePadding = 0
+ config.contentInsets = .zero
+ config.baseForegroundColor = isDestructive ? .systemRed : .label
+ button.configuration = config
+ }
+
+ /// Scales icons that aren't symbols, so they match the symbols' size
+ private static func icon(_ image: UIImage?) -> UIImage? {
+ guard
+ let image,
+ !image.isSymbolImage,
+ image.size.width > 0, image.size.height > 0
+ else { return image }
+
+ let scale = min(iconSize / image.size.width, iconSize / image.size.height)
+ guard abs(scale - 1) > 0.01 else { return image }
+
+ let size = CGSize(width: (image.size.width * scale).rounded(), height: (image.size.height * scale).rounded())
+ let scaled = UIGraphicsImageRenderer(size: size).image { _ in
+ image.draw(in: CGRect(origin: .zero, size: size))
+ }
+ return scaled.withRenderingMode(image.renderingMode)
+ }
+
+}
+
+// MARK: - Routing presentations
+
+extension TGCardViewController {
+
+ /// Presents on top of the sheet, or whatever is on top of it.
+ ///
+ /// The sheet is this controller's `presentedViewController`, so presenting
+ /// from here directly would fail.
+ func routePresentation(of viewControllerToPresent: UIViewController, above sheetHost: TGSheetHostViewController, animated: Bool, completion: (() -> Void)?) {
+ // A presented view controller defines a presentation context, so presenting
+ // over the current context would only cover the sheet.
+ switch viewControllerToPresent.modalPresentationStyle {
+ case .currentContext: viewControllerToPresent.modalPresentationStyle = .fullScreen
+ case .overCurrentContext: viewControllerToPresent.modalPresentationStyle = .overFullScreen
+ default: break
+ }
+
+ var presenter: UIViewController = sheetHost
+ while let next = presenter.presentedViewController, !next.isBeingDismissed {
+ presenter = next
+ }
+
+ if presenter === sheetHost {
+ sheetHost.presentDirectly(viewControllerToPresent, animated: animated, completion: completion)
+ } else {
+ presenter.present(viewControllerToPresent, animated: animated, completion: completion)
+ }
+ }
+
+ /// Dismisses what's on top of the sheet, but never the sheet itself.
+ func routeDismissal(above sheetHost: TGSheetHostViewController, animated: Bool, completion: (() -> Void)?) {
+ if sheetHost.presentedViewController != nil {
+ sheetHost.dismissPresented(animated: animated) { [weak self] in
+ completion?()
+ self?.updateSystemSheetPresentation()
+ }
+ } else if let presenting = presentingViewController {
+ // Nothing's on top of the sheet, but we're presented ourselves. Like
+ // UIKit, dismiss us then, which takes the sheet along.
+ presenting.dismiss(animated: animated, completion: completion)
+ } else {
+ // Nothing to dismiss
+ completion?()
+ }
+ }
+
+}
+
+// MARK: - Detents
+
+#if !os(visionOS) // No detents on visionOS, where cards never use a sheet
+extension TGCardPosition {
+
+ var sheetDetentIdentifier: UISheetPresentationController.Detent.Identifier {
+ .init("tg.\(rawValue)")
+ }
+
+ init?(sheetDetentIdentifier identifier: UISheetPresentationController.Detent.Identifier?) {
+ guard let raw = identifier?.rawValue, raw.hasPrefix("tg.") else { return nil }
+ self.init(rawValue: String(raw.dropFirst(3)))
+ }
+
+}
+#endif
+
+extension TGCardViewController {
+
+#if !os(visionOS) // No detents on visionOS, where cards never use a sheet
+
+ /// The positions that the sheet can rest at, smallest first.
+ ///
+ /// - Parameter position: The position the sheet should rest at
+ private func sheetPositions(selecting position: TGCardPosition) -> [TGCardPosition] {
+ let forceExtended = topCard?.mapManager == nil
+ if forceExtended || !isSheetDraggingEnabled {
+ // Pin the sheet where it should be
+ return [position]
+ } else {
+ return [.collapsed, .peaking, .extended]
+ }
+ }
+
+ private func makeSheetDetent(for position: TGCardPosition) -> UISheetPresentationController.Detent {
+ .custom(identifier: position.sheetDetentIdentifier) { [weak self] context in
+ MainActor.assumeIsolated {
+ self?.sheetDetentValue(for: position, in: context) ?? context.maximumDetentValue
+ }
+ }
+ }
+
+ private func sheetDetentValue(for position: TGCardPosition, in context: any UISheetPresentationControllerDetentResolutionContext) -> CGFloat {
+ let maximum = context.maximumDetentValue
+
+ var extended = maximum
+ if isShowingHeader {
+ // Keep the header visible above the sheet
+ let headerBottom = headerViewTopConstraint.constant + headerViewHeightConstraint.constant
+ let available = view.bounds.height - view.safeAreaInsets.bottom - headerBottom - Constants.sheetSpacingBelowHeader
+ extended = min(extended, available)
+ }
+
+ // Shows the title, with as much space below it as above it, where the
+ // sheet floats. A taller sheet attaches to the bottom edge and adds the
+ // bottom safe area below the title, so there it ends where the header does.
+ var collapsed = topCardView?.headerHeight(for: .collapsed) ?? 0
+ if let balanced = topCardView?.sheetCollapsedHeight(), balanced <= Constants.sheetMaximumFloatingHeight {
+ collapsed = balanced
+ }
+ collapsed = max(Constants.minCardHeightWhenCollapsed, collapsed)
+
+ var peaking = UISheetPresentationController.Detent.medium().resolvedValue(in: context) ?? 0
+ if peaking <= 0 || peaking >= extended {
+ peaking = extended / 2
+ }
+
+ // Keep them in order, and remember them all, as only the detents the sheet
+ // currently uses get resolved.
+ let values: [TGCardPosition: CGFloat] = [
+ .collapsed: collapsed,
+ .peaking: max(peaking, collapsed + 1),
+ .extended: max(extended, peaking + 1, collapsed + 2),
+ ]
+ sheetDetentValues = values
+ return values[position] ?? maximum
+ }
+
+ /// Updates the sheet's detents and selected detent.
+ ///
+ /// Doesn't animate by itself; call this from within `animateChanges` if needed.
+ private func applySheetConfiguration(to sheet: UISheetPresentationController, selecting position: TGCardPosition) {
+ let positions = sheetPositions(selecting: position)
+ let identifiers = positions.map(\.sheetDetentIdentifier)
+
+ if sheet.detents.map(\.identifier) != identifiers {
+ sheet.detents = positions.map(makeSheetDetent(for:))
+ } else {
+ sheet.invalidateDetents()
+ }
+
+ let selected = positions.contains(position) ? position : (positions.last ?? .extended)
+ if sheet.selectedDetentIdentifier != selected.sheetDetentIdentifier {
+ sheet.selectedDetentIdentifier = selected.sheetDetentIdentifier
+ }
+
+ // Never dim, so that the map and the header stay interactive
+ if sheet.largestUndimmedDetentIdentifier != identifiers.last {
+ sheet.largestUndimmedDetentIdentifier = identifiers.last
+ }
+
+ let showGrabber = positions.count > 1
+ if sheet.prefersGrabberVisible != showGrabber {
+ sheet.prefersGrabberVisible = showGrabber
+ }
+ }
+
+#endif
+
+ /// Moves the sheet to the provided position, or refreshes it in place.
+ ///
+ /// - Parameters:
+ /// - position: Where the sheet should rest; defaults to where it's going or
+ /// resting already
+ /// - animated: Whether to animate the change
+ /// - completion: Called once the sheet has moved
+ func applySheetPosition(_ position: TGCardPosition? = nil, animated: Bool, completion: (() -> Void)? = nil) {
+#if os(visionOS)
+ completion?()
+#else
+ guard let sheet = sheetHost?.sheetPresentationController else {
+ completion?()
+ return
+ }
+
+ let target = position ?? sheetTargetPosition ?? cardPosition
+ sheetTargetPosition = target
+
+ guard animated else {
+ applySheetConfiguration(to: sheet, selecting: target)
+ completion?()
+ return
+ }
+
+ CATransaction.begin()
+ CATransaction.setCompletionBlock(completion)
+ sheet.animateChanges {
+ self.applySheetConfiguration(to: sheet, selecting: target)
+ }
+ CATransaction.commit()
+#endif
+ }
+
+ /// The sheet's position, if the cards are shown in a sheet.
+ var sheetPosition: TGCardPosition? {
+#if os(visionOS)
+ return nil
+#else
+ guard let sheet = sheetHost?.sheetPresentationController else { return nil }
+ return TGCardPosition(sheetDetentIdentifier: sheet.selectedDetentIdentifier)
+ ?? TGCardPosition(sheetDetentIdentifier: sheet.detents.first?.identifier)
+#endif
+ }
+
+ /// The height of the sheet when it rests at the provided position, if known.
+ func sheetHeight(for position: TGCardPosition) -> CGFloat? {
+ sheetDetentValues[position].map { $0 + view.safeAreaInsets.bottom }
+ }
+
+ private func estimatedSheetContentHeight(in host: TGSheetHostViewController) -> CGFloat {
+ let extended = sheetHeight(for: .extended) ?? (view.bounds.height - extendedMinY)
+ return max(extended, host.view.bounds.height)
+ }
+
+}
+
+// MARK: - Following the sheet
+
+extension TGCardViewController {
+
+ /// Called whenever the sheet lays out, including while it's being dragged.
+ func sheetHostDidLayoutSubviews() {
+ guard
+ let host = sheetHost,
+ let window = view.window,
+ host.view.window === window
+ else { return }
+
+ syncSheetHostAppearance(host)
+
+ // Content height follows the extended detent
+ if let heightConstraint = sheetContentHeightConstraint {
+ let height = estimatedSheetContentHeight(in: host)
+ if abs(heightConstraint.constant - height) > 0.5 {
+ heightConstraint.constant = height
+ }
+ }
+
+ // A sheet that doesn't span the width keeps the map clear next to it,
+ // rather than below it
+ let sheetFrame = host.view.convert(host.view.bounds, to: view)
+ if sheetFrame.width < view.bounds.width * 0.75 {
+ var insets = UIEdgeInsets.zero
+ if view.effectiveUserInterfaceLayoutDirection == .rightToLeft {
+ insets.right = max(0, view.bounds.maxX - sheetFrame.minX - view.safeAreaInsets.right)
+ } else {
+ insets.left = max(0, sheetFrame.maxX - view.safeAreaInsets.left)
+ }
+ if mapViewController.additionalSafeAreaInsets != insets {
+ mapViewController.additionalSafeAreaInsets = insets
+ }
+ alignHeader(withSheetFrame: sheetFrame)
+ fadeCardContent(forSheetTop: sheetFrame.minY)
+ syncVerticalBarItems(sheetTop: sheetFrame.minY)
+ return
+ }
+
+ // The invisible card wrapper mirrors the top of the sheet, which moves the
+ // map buttons and the map's insets along with the sheet. Like the card
+ // wrapper, its position is relative to the bottom of the header.
+ let sheetTop = host.view.convert(host.view.bounds, to: view).minY
+ let y = sheetTop - max(0, headerView.frame.maxY)
+ if abs(cardWrapperDesiredTopConstraint.constant - y) > 0.5 {
+ let insets = updateCardPosition(y: y)
+ if mapViewController.additionalSafeAreaInsets != insets {
+ mapViewController.additionalSafeAreaInsets = insets
+ }
+ }
+
+ fadeMapFloatingViews(forSheetTop: sheetTop)
+ fadeCardContent(forSheetTop: sheetTop)
+
+ // When the sheet settles, this is called once from within the sheet's
+ // animation. Laying out now has the map buttons and insets follow along,
+ // rather than jumping ahead.
+ view.layoutIfNeeded()
+
+ syncVerticalBarItems(sheetTop: sheetTop)
+ }
+
+ /// Like the classic card does while dragging, fade the card's content from
+ /// visible at the peaking height to hidden at the collapsed one, where the
+ /// sheet only shows the card's title.
+ private func fadeCardContent(forSheetTop sheetTop: CGFloat) {
+ guard let cardView = topCardView, let alpha = sheetExpansion(forSheetTop: sheetTop) else { return }
+ updateSheetBackground(alpha: alpha)
+
+ if let current = cardView.contentScrollView?.alpha, abs(current - alpha) < 0.01 {
+ return
+ }
+ cardView.adjustContentAlpha(to: alpha)
+ }
+
+ /// How far the sheet is raised, from 0 when collapsed to 1 when peaking or
+ /// higher, if the heights are known
+ func sheetExpansion(forSheetTop sheetTop: CGFloat) -> CGFloat? {
+ guard
+ let collapsedHeight = sheetHeight(for: .collapsed),
+ let peakingHeight = sheetHeight(for: .peaking),
+ peakingHeight > collapsedHeight
+ else { return nil }
+
+ let height = sheetBottom - sheetTop
+ return min(1, max(0, (height - collapsedHeight) / (peakingHeight - collapsedHeight)))
+ }
+
+ /// The bottom of the sheet in this controller's view, which can float above
+ /// the bottom edge, so heights are measured from there
+ private var sheetBottom: CGFloat {
+ guard let host = sheetHost, host.view.window != nil else { return view.bounds.height }
+ return host.view.convert(host.view.bounds, to: view).maxY
+ }
+
+ /// Shows the top card's `expandedBackgroundColor` across the whole sheet,
+ /// and the header above it, so that the header matches the card below
+ private func updateSheetBackground(alpha: CGFloat) {
+ updateHeaderBackground(alpha: alpha)
+
+ guard let host = sheetHost, let color = topCard?.style.expandedBackgroundColor else {
+ sheetBackgroundView?.removeFromSuperview()
+ sheetBackgroundView = nil
+ return
+ }
+
+ let background: UIView
+ if let existing = sheetBackgroundView, existing.superview === host.view {
+ background = existing
+ } else {
+ sheetBackgroundView?.removeFromSuperview()
+ background = UIView()
+ background.isUserInteractionEnabled = false
+ background.translatesAutoresizingMaskIntoConstraints = false
+ host.view.insertSubview(background, at: 0)
+ NSLayoutConstraint.activate([
+ background.topAnchor.constraint(equalTo: host.view.topAnchor),
+ background.bottomAnchor.constraint(equalTo: host.view.bottomAnchor),
+ background.leadingAnchor.constraint(equalTo: host.view.leadingAnchor),
+ background.trailingAnchor.constraint(equalTo: host.view.trailingAnchor),
+ ])
+ sheetBackgroundView = background
+ }
+ if background.backgroundColor != color {
+ background.backgroundColor = color
+ }
+ if abs(background.alpha - alpha) > 0.01 {
+ background.alpha = alpha
+ }
+ }
+
+ /// Puts the same background behind the header as `updateSheetBackground`
+ /// puts behind the card: between the header's glass and its content, as the
+ /// sheet's is between its glass and the card
+ private func updateHeaderBackground(alpha: CGFloat) {
+ guard let color = topCard?.style.expandedBackgroundColor else {
+ headerSheetBackgroundView?.removeFromSuperview()
+ headerSheetBackgroundView = nil
+ return
+ }
+
+ let container = headerEffectView.contentView
+ let background: UIView
+ if let existing = headerSheetBackgroundView, existing.superview === container {
+ background = existing
+ } else {
+ headerSheetBackgroundView?.removeFromSuperview()
+ background = UIView()
+ background.isUserInteractionEnabled = false
+ background.translatesAutoresizingMaskIntoConstraints = false
+ container.insertSubview(background, at: 0)
+ NSLayoutConstraint.activate([
+ background.topAnchor.constraint(equalTo: container.topAnchor),
+ background.bottomAnchor.constraint(equalTo: container.bottomAnchor),
+ background.leadingAnchor.constraint(equalTo: container.leadingAnchor),
+ background.trailingAnchor.constraint(equalTo: container.trailingAnchor),
+ ])
+ headerSheetBackgroundView = background
+ matchHeaderBackgroundCorners()
+ }
+ if background.backgroundColor != color {
+ background.backgroundColor = color
+ }
+ if abs(background.alpha - alpha) > 0.01 {
+ background.alpha = alpha
+ }
+ }
+
+ /// The header's glass doesn't clip its content to its rounded corners, so
+ /// give the background the same corners
+ func matchHeaderBackgroundCorners() {
+#if !os(visionOS)
+ if #available(iOS 26.0, *), let background = headerSheetBackgroundView {
+ background.cornerConfiguration = headerEffectView.cornerConfiguration
+ }
+#endif
+ }
+
+ /// Like the classic card does while dragging, fade out the map's buttons as
+ /// the sheet moves up from the peaking position, so that they don't end up
+ /// under the status bar.
+ private func fadeMapFloatingViews(forSheetTop sheetTop: CGFloat) {
+ guard
+ allowFloatingViews,
+ let peakingHeight = sheetHeight(for: .peaking),
+ let extendedHeight = sheetHeight(for: .extended)
+ else { return }
+
+ let peakingTop = sheetBottom - peakingHeight
+ let extendedTop = sheetBottom - extendedHeight
+ guard peakingTop > extendedTop else { return }
+
+ let fade = min(1, max(0, (peakingTop - sheetTop) / ((peakingTop - extendedTop) * 0.3)))
+ topFloatingViewWrapper.alpha = 1 - fade
+ bottomFloatingViewWrapper.alpha = 1 - fade
+ }
+
+ /// Called when the user dragged the sheet to a different detent.
+ func sheetDidChangeSelectedDetent() {
+ guard let position = sheetPosition else { return }
+ sheetTargetPosition = position
+
+ UIView.animate(withDuration: Constants.tapAnimationDuration) {
+ self.updateFloatingViewsVisibility(for: position)
+ }
+ topCard?.mapManager?.edgePadding = mapEdgePadding(for: position)
+ topCard?.didMove(to: position, animated: true)
+ updateForNewPosition(position: position)
+ }
+
+}
diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift
index b4ba27a..a1bf30e 100644
--- a/Sources/TGCardViewController/TGCardViewController.swift
+++ b/Sources/TGCardViewController/TGCardViewController.swift
@@ -81,14 +81,14 @@ public protocol TGCardViewControllerDelegate: AnyObject {
@MainActor
open class TGCardViewController: UIViewController {
- fileprivate enum Constants {
+ enum Constants {
/// The minimum number of points between the status bar and the
/// top of the card to keep a bit of the map always showing through.
fileprivate static let minMapSpace: CGFloat = 50
fileprivate static let minMapSpaceWithHomeIndicator: CGFloat = 12
- fileprivate static let minCardHeightWhenCollapsed: CGFloat = 44 * 0.66
+ static let minCardHeightWhenCollapsed: CGFloat = 44 * 0.66
fileprivate static let pushAnimationDuration = 0.25
@@ -97,13 +97,27 @@ open class TGCardViewController: UIViewController {
fileprivate static let snapAnimationMinimumDuration = 0.4
/// The seconds for switching the top card to its new location after tapping it.
- fileprivate static let tapAnimationDuration = 0.25
+ static let tapAnimationDuration = 0.25
fileprivate static let mapShadowVisibleAlpha: CGFloat = 0.25
fileprivate static let floatingHeaderTopMargin: CGFloat = 20
fileprivate static let iOS26CornerRadius: CGFloat = 32
+
+ /// The space between the header and a system sheet, when the sheet is extended.
+ static let sheetSpacingBelowHeader: CGFloat = 8
+
+ /// The extra space around a header's content above a system sheet
+ static let sheetHeaderContentInset: CGFloat = 8
+
+ /// The tallest a collapsed sheet can be while it still floats above the
+ /// bottom edge, rather than attaching to it with the bottom safe area added
+ /// below the content. Observed on iOS 26 for custom detents.
+ static let sheetMaximumFloatingHeight: CGFloat = 100
+
+ /// Pushing and popping cross-fade between cards in a system sheet
+ static let sheetCrossFadeDuration = 0.25
}
public enum Mode {
@@ -136,6 +150,71 @@ open class TGCardViewController: UIViewController {
#endif
}()
+ /// How the cards are presented: positioned by this controller itself, or in a
+ /// system sheet. Defaults to ``PresentationStyle-swift.enum/automatic``, which
+ /// uses system sheets on iOS 27+. Check ``usesSystemSheet`` for what's
+ /// currently used.
+ ///
+ /// - Warning: Set before `viewDidLoad` is called.
+ public var presentationStyle: PresentationStyle = .automatic
+
+ // MARK: System sheet state, see `TGCardViewController+SystemSheet.swift`
+
+ /// Hosts the cards while they're shown in a system sheet
+ var sheetHost: TGSheetHostViewController?
+ var sheetContentHeightConstraint: NSLayoutConstraint?
+ var sheetContentLeftConstraint: NSLayoutConstraint?
+ var sheetContentRightConstraint: NSLayoutConstraint?
+ /// Header edges pinned to the card, swapped for those pinned to the screen
+ /// when above a system sheet; see `updateHeaderForSheet`
+ var headerCardEdgeConstraints: [NSLayoutConstraint] = []
+ var headerScreenEdgeConstraints: [NSLayoutConstraint] = []
+ /// Matches the background of the card in the system sheet; see `updateHeaderBackground`
+ var headerSheetBackgroundView: UIView?
+ var headerSheetAlignedConstraints: [NSLayoutConstraint] = []
+ var headerEffectToScreenTopConstraints: [NSLayoutConstraint] = []
+ var headerEffectToHeaderTopConstraints: [NSLayoutConstraint] = []
+ /// The distance between the side of the screen and a system sheet on that
+ /// side, which a header above the sheet keeps to the top
+ var headerSheetGap: CGFloat = 8
+ var savedCardContentConstraints: [NSLayoutConstraint] = []
+ var savedCardWrapperEffect: UIVisualEffect?
+ var sheetDetentValues: [TGCardPosition: CGFloat] = [:]
+ var sheetTargetPosition: TGCardPosition?
+ var isSheetDraggingEnabled = true
+
+ /// Set while the initial cards wait for the sheet, so that they don't flash up
+ /// in the classic presentation first
+ var isAwaitingSystemSheet = false
+
+ /// Set while pushing or popping, which update the sheet in one go
+ var isTransitioningCards = false
+
+ /// The top card's close button and bar actions, placed in the vertical bar
+ /// on devices that have one, e.g., iPhone Duo. This one is over the map...
+ var mapVerticalBarItems: TGVerticalBarItemsView?
+
+ /// Shows the top card's `expandedBackgroundColor` across the whole sheet,
+ /// including below a vertical bar, where the cards themselves don't go
+ var sheetBackgroundView: UIView?
+
+ /// ... and this one is on the sheet, for when that covers the vertical bar.
+ var sheetVerticalBarItems: TGVerticalBarItemsView?
+
+ var verticalBarItemsEdge: NSDirectionalRectEdge?
+
+ /// Set while a collapsed sheet is next to a vertical bar on the trailing
+ /// edge, where the bottom map buttons then ignore the bar's safe area inset,
+ /// like the collapsed card's title does
+ var bottomFloatingViewIgnoresVerticalBar = false
+
+ /// Where the vertical bar is, if any, see `updateLayoutForVerticalBar`
+ var verticalBarColumnGuide: (edge: NSDirectionalRectEdge, guide: UILayoutGuide)?
+
+ /// The close buttons in the titles of the top card, hidden while the bar
+ /// items stand in for them
+ var suppressedCloseButtons: [UIButton] = []
+
/// A Boolean value that specifies whether the close buttons
/// on cards and headers are participating in spring-loaded
/// interaction for a drag and drop activity.
@@ -250,7 +329,7 @@ open class TGCardViewController: UIViewController {
/// Horizontal stack hosting the top card's `floatingCardToolBarItems`. Added
/// to the controller's view (not the card), pinned to the bottom of the
/// card's visible area and clamped to the safe area so it stays on screen.
- private lazy var cardFloatingView: UIStackView = {
+ lazy var cardFloatingView: UIStackView = {
let stack = UIStackView()
stack.axis = .horizontal
stack.spacing = 8
@@ -285,14 +364,20 @@ open class TGCardViewController: UIViewController {
didSet { if isViewLoaded, defaultButtons != nil { updateMapToolbarItems() } }
}
- private var allowFloatingViews: Bool = true
+ var allowFloatingViews: Bool = true
public var draggingCardEnabled: Bool {
get {
- panner.isEnabled
+ usesSystemSheet ? isSheetDraggingEnabled : panner.isEnabled
}
set {
- panner.isEnabled = newValue
+ isSheetDraggingEnabled = newValue
+ if usesSystemSheet {
+ // Pins the sheet to where it is or is going
+ applySheetPosition(animated: true)
+ } else {
+ panner.isEnabled = newValue
+ }
}
}
@@ -303,14 +388,14 @@ open class TGCardViewController: UIViewController {
private var isDraggingCard = false
- fileprivate var isVisible = false
+ var isVisible = false
/// To stop popping too quickly which messes things up
private var isPopping = false
fileprivate var previousCardPosition: TGCardPosition?
- fileprivate var cards = [(card: TGCard, lastPosition: TGCardPosition, view: TGCardView?)]()
+ var cards = [(card: TGCard, lastPosition: TGCardPosition, view: TGCardView?)]()
// Before pushing a header that extends to the top
private var previousStatusBarStyle: UIStatusBarStyle?
@@ -428,7 +513,8 @@ open class TGCardViewController: UIViewController {
/// overflowing the card for any alignment.
private func installCardFloatingViewIfNeeded() {
guard cardFloatingView.superview == nil else { return }
- view.addSubview(cardFloatingView)
+ let container = cardOverlayView
+ container.addSubview(cardFloatingView)
cardFloatingCenterXConstraint = cardFloatingView.centerXAnchor.constraint(equalTo: cardWrapperContent.centerXAnchor)
cardFloatingLeadingConstraint = cardFloatingView.leadingAnchor.constraint(equalTo: cardWrapperContent.leadingAnchor, constant: 16)
@@ -437,7 +523,7 @@ open class TGCardViewController: UIViewController {
NSLayoutConstraint.activate([
cardFloatingView.leadingAnchor.constraint(greaterThanOrEqualTo: cardWrapperContent.leadingAnchor, constant: 16),
cardFloatingView.trailingAnchor.constraint(lessThanOrEqualTo: cardWrapperContent.trailingAnchor, constant: -16),
- cardFloatingView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16),
+ cardFloatingView.bottomAnchor.constraint(equalTo: container.safeAreaLayoutGuide.bottomAnchor, constant: -16),
])
applyCardFloatingAlignment(.center)
@@ -506,7 +592,7 @@ open class TGCardViewController: UIViewController {
override open func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
- if view.superview != nil {
+ if view.superview != nil, !usesSystemSheet {
// This is the distance from the top edge of the card to the
// bottom of the header view and determines where the card
// rests on the screen.
@@ -544,6 +630,8 @@ open class TGCardViewController: UIViewController {
topCard?.didAppear(animated: animated)
isVisible = true
+
+ updateSystemSheetPresentation()
}
override open func viewWillDisappear(_ animated: Bool) {
@@ -551,6 +639,36 @@ open class TGCardViewController: UIViewController {
topCard?.willDisappear(animated: animated)
isVisible = false
+
+ // The sheet would otherwise stay on screen when, say, another view
+ // controller gets pushed over this one. It comes back in `viewDidAppear`.
+ if sheetHost?.presentedViewController == nil {
+ uninstallSystemSheet(animated: false)
+ }
+ }
+
+ // MARK: - Presenting
+
+ // `@preconcurrency` as callers might not be isolated to the main actor yet,
+ // which UIKit tolerates for its own `present` and `dismiss`.
+
+ @preconcurrency
+ open override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
+ guard let sheetHost, viewControllerToPresent !== sheetHost else {
+ return super.present(viewControllerToPresent, animated: flag, completion: completion)
+ }
+ routePresentation(of: viewControllerToPresent, above: sheetHost, animated: flag, completion: completion)
+ }
+
+ @preconcurrency
+ open override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
+ guard let sheetHost, presentedViewController === sheetHost else {
+ return super.dismiss(animated: flag) { [weak self] in
+ completion?()
+ self?.updateSystemSheetPresentation()
+ }
+ }
+ routeDismissal(above: sheetHost, animated: flag, completion: completion)
}
override open func viewDidDisappear(_ animated: Bool) {
@@ -573,7 +691,7 @@ open class TGCardViewController: UIViewController {
cardWrapperHeightConstraint.constant = extendedMinY * -1
// When trait collection changes, try to keep the same card position
- if let previous = previousCardPosition {
+ if let previous = previousCardPosition, !usesSystemSheet {
// Note: Ideally, we'd determine the direction by whether the available
// height of VC increased or decreased, but for simplicity just using
// `up` is fine.
@@ -597,8 +715,8 @@ open class TGCardViewController: UIViewController {
topCard?.traitCollectionDidChange(previousTraitCollection)
}
- private func updateCardScrolling(allow: Bool, view: TGCardView?) {
- let allowScrolling = allow || UIAccessibility.isVoiceOverRunning || mode == .sidebar
+ func updateCardScrolling(allow: Bool, view: TGCardView?) {
+ let allowScrolling = allow || UIAccessibility.isVoiceOverRunning || mode == .sidebar || usesSystemSheet
view?.allowContentScrolling(allowScrolling)
}
@@ -608,10 +726,23 @@ open class TGCardViewController: UIViewController {
if view.superview != nil, !mapView.frame.isEmpty {
if !didAddInitialCards {
didAddInitialCards = true
+
+ // These get pushed the classic way and move into the sheet once we've
+ // appeared. Keep them hidden until then.
+ isAwaitingSystemSheet = wantsSystemSheet
+
initialCards.forEach { push($0, animated: false, allowToNotify: $0 == initialCards.last, completionHandler: nil) }
}
fixPositioning()
+
+ if isVisible, !usesSystemSheet, presentedViewController == nil, topCardView != nil, wantsSystemSheet {
+ // We wanted a sheet earlier, but couldn't show it, e.g., as something
+ // else was presented at the time. Try again.
+ DispatchQueue.main.async { [weak self] in
+ self?.updateSystemSheetPresentation()
+ }
+ }
}
}
@@ -619,8 +750,8 @@ open class TGCardViewController: UIViewController {
let previousScrollOffset = topCardView?.contentScrollView?.contentOffset.y
statusBarBlurHeightConstraint.constant = topOverlap
- topCardView?.adjustContentAlpha(to: cardPosition == .collapsed ? 0 : 1)
- topCardView?.setSeparatorVisibility(forceHidden: cardPosition == .collapsed)
+ topCardView?.adjustContentAlpha(to: contentAlpha(for: cardPosition))
+ topCardView?.setSeparatorVisibility(forceHidden: forcesSeparatorHidden(for: cardPosition))
updateFloatingViewsConstraints()
updateTopInfoViewConstraints()
view.setNeedsUpdateConstraints()
@@ -635,7 +766,16 @@ open class TGCardViewController: UIViewController {
if let scrollView = topCardView?.contentScrollView {
view.updateConstraintsIfNeeded() // to get the correct frames
- let adjustedBottom = cardIsNextToMap(in: traitCollection) ? view.safeAreaInsets.bottom : (headerView.frame.maxY + view.safeAreaInsets.top - view.safeAreaInsets.bottom)
+ let adjustedBottom: CGFloat
+ if usesSystemSheet {
+ // The cards are as high as the extended sheet, and the scroll view
+ // adjusts for the sheet's safe area by itself
+ adjustedBottom = 0
+ } else if cardIsNextToMap(in: traitCollection) {
+ adjustedBottom = view.safeAreaInsets.bottom
+ } else {
+ adjustedBottom = headerView.frame.maxY + view.safeAreaInsets.top - view.safeAreaInsets.bottom
+ }
scrollView.contentInset.bottom = adjustedBottom
scrollView.verticalScrollIndicatorInsets.bottom = adjustedBottom
@@ -653,6 +793,7 @@ open class TGCardViewController: UIViewController {
/// The current card position, inferred from the current drag position of the card
public var cardPosition: TGCardPosition {
guard mode == .floating else { return .extended }
+ if let sheetPosition { return sheetPosition }
let cardY = cardWrapperDesiredTopConstraint.constant
@@ -668,7 +809,7 @@ open class TGCardViewController: UIViewController {
}
}
- fileprivate var extendedMinY: CGFloat {
+ var extendedMinY: CGFloat {
var value = topOverlap
if mode == .floating {
@@ -704,13 +845,19 @@ open class TGCardViewController: UIViewController {
/// card positions, and capped at the peaking card position
/// for the extended overlap (to avoid only having a tiny
/// map area to work with).
- fileprivate func mapEdgePadding(for position: TGCardPosition) -> UIEdgeInsets {
+ func mapEdgePadding(for position: TGCardPosition) -> UIEdgeInsets {
assert(mapView.frame.isEmpty == false, "Don't call this before we have a map view frame.")
let top: CGFloat
let bottom: CGFloat
let left: CGFloat
- if cardIsNextToMap(in: traitCollection) {
+ if usesSystemSheet, let sheetBottom = sheetHeight(for: position == .collapsed ? .collapsed : .peaking) {
+ // As below, the card's overlap but capped at the peaking position
+ left = 0
+ top = isShowingHeader ? 0 : topOverlap
+ bottom = sheetBottom
+
+ } else if cardIsNextToMap(in: traitCollection) {
// The map is to the right of the card, which we account for when not collapsed
let ignoreCard = (position == .collapsed && traitCollection.verticalSizeClass == .regular) || cardWrapperShadow.isHidden
left = ignoreCard ? 0 : cardWrapperShadow.frame.maxX
@@ -740,18 +887,21 @@ open class TGCardViewController: UIViewController {
/// Call this whenever the card position changes to properly configure the map shadow
///
/// - Parameter position: New card position
- fileprivate func updateMapShadow(for position: TGCardPosition) {
- mapShadow.alpha = position == .extended ? Constants.mapShadowVisibleAlpha : 0
- mapShadow.isUserInteractionEnabled = position == .extended
+ func updateMapShadow(for position: TGCardPosition) {
+ // The map stays interactive next to a system sheet
+ let showShadow = position == .extended && !usesSystemSheet
+ mapShadow.alpha = showShadow ? Constants.mapShadowVisibleAlpha : 0
+ mapShadow.isUserInteractionEnabled = showShadow
}
- private func toggleCardWrappers(hide: Bool, prepareOnly: Bool = false) {
+ func toggleCardWrappers(hide: Bool, prepareOnly: Bool = false) {
if mode == .sidebar {
sidebarBackground.alpha = hide ? 0 : 1
} else {
sidebarBackground.alpha = 0
}
- cardWrapperShadow.alpha = hide ? 0 : 1
+ // With a system sheet, the card wrapper is just an invisible placeholder
+ cardWrapperShadow.alpha = hide || usesSystemSheet || isAwaitingSystemSheet ? 0 : 1
if !prepareOnly {
if mode == .sidebar {
@@ -820,7 +970,7 @@ extension TGCardViewController {
return cards.last?.card
}
- private var topCardView: TGCardView? {
+ var topCardView: TGCardView? {
return cards.last?.view
}
@@ -830,7 +980,7 @@ extension TGCardViewController {
extension TGCardViewController {
- fileprivate func cardLocation(forDesired desired: TGCardPosition?, direction: Direction)
+ func cardLocation(forDesired desired: TGCardPosition?, direction: Direction)
-> (position: TGCardPosition, y: CGFloat) {
guard mode == .floating else {
@@ -885,6 +1035,9 @@ extension TGCardViewController {
// bar, which requires access to this property.
top.controller = self
+ isTransitioningCards = true
+ defer { isTransitioningCards = false }
+
// 1. Determine where the new card will go
let forceExtended = (top.mapManager == nil) || (cardPosition == .extended && UIAccessibility.isVoiceOverRunning)
let animateTo = cardLocation(forDesired: forceExtended ? .extended : top.initialPosition, direction: .down)
@@ -923,8 +1076,8 @@ extension TGCardViewController {
// below, since the card view is placed on the top of the bottom safe layout guide,
// which is an additional 34px on iPhone X, we will see part of the card content
// coming through.
- cardView.adjustContentAlpha(to: animateTo.position == .collapsed ? 0 : 1)
- cardView.setSeparatorVisibility(forceHidden: animateTo.position == .collapsed)
+ cardView.adjustContentAlpha(to: contentAlpha(for: animateTo.position))
+ cardView.setSeparatorVisibility(forceHidden: forcesSeparatorHidden(for: animateTo.position))
// This allows us to continuously pull down the card view while its
// content is scrolled to the top. Note this only applies when the
@@ -935,18 +1088,17 @@ extension TGCardViewController {
// 4. Place the new view coming, preparing to animate in from the bottom
cardView.frame = cardWrapperContent.bounds
- if animated {
+ if animated, usesSystemSheet {
+ // Cross-fade in place, as the sheet is already there. Sliding up a card
+ // within the sheet would look odd.
+ cardView.alpha = 0
+ } else if animated {
let offset = cardView.convert(.init(x: 0, y: mapViewWrapper.frame.maxY), to: cardWrapperShadow).y
cardView.frame.origin.y = offset
cardWrapperEffectView.frame.origin.y = offset
}
- if #available(iOS 26.0, visionOS 26.0, *), mode == .floating {
- // Match the corners of the glass behind the card, so that cards with a
- // non-clear background don't stick out at the corners
- cardView.cornerConfiguration = cardWrapperEffectView.cornerConfiguration
- cardView.clipsToBounds = true
- }
+ applyPresentationStyle(to: cardView)
cardWrapperContent.addSubview(cardView)
// Give AutoLayout a nudge to layout the card view, now that we have
@@ -983,7 +1135,13 @@ extension TGCardViewController {
// 6. Set new position of the wrapper (which is relative to the header)
updateCardStructure(card: cardView, position: .collapsed)
- mapViewController.additionalSafeAreaInsets = updateCardPosition(y: animateTo.y)
+ if usesSystemSheet {
+ // Like with the classic panner, pushing re-enables dragging
+ isSheetDraggingEnabled = true
+ sheetTargetPosition = animateTo.position
+ } else {
+ mapViewController.additionalSafeAreaInsets = updateCardPosition(y: animateTo.y)
+ }
// Notify that we have completed building the card view and its header view.
top.cardView = cardView
@@ -994,6 +1152,9 @@ extension TGCardViewController {
// floating items now that the card has been built, so they aren't missed.
updateCardFloatingViewContent(card: top)
+ // Before the new card fades in, so that its own close button doesn't
+ updateVerticalBarItems()
+
// The previous call can cause a glitch where the render loop is run, if
// the cards to certain things. To avoid this, we revert back to the old
// frame, just in case.
@@ -1032,8 +1193,8 @@ extension TGCardViewController {
// old. Only do that if the previous transition completed, i.e., we didn't
// already have such a shadow.
- if let oldTop, animated, let cardView, cardTransitionShadow == nil {
- if #available(iOS 26.0, visionOS 26.0, *), let oldView = oldTop.view, let container = cardWrapperShadow.superview, let snapshot = oldView.snapshotView(afterScreenUpdates: false) {
+ if let oldTop, animated, let cardView, cardTransitionShadow == nil, !usesSystemSheet {
+ if #available(iOS 26.0, visionOS 26.0, *), let oldView = oldTop.view, let container = cardTransitionContainer, let snapshot = oldView.snapshotView(afterScreenUpdates: false) {
var oldFrame = oldView.bounds
oldFrame.origin = oldView.convert(oldView.bounds.origin, to: container)
oldView.alpha = 0
@@ -1045,7 +1206,7 @@ extension TGCardViewController {
#endif
visualEffectView.contentView.addSubview(snapshot)
visualEffectView.frame = oldFrame
- container.insertSubview(visualEffectView, belowSubview: cardWrapperShadow)
+ container.insertSubview(visualEffectView, belowSubview: cardTransitionAnchor)
cardTransitionShadow = visualEffectView
} else {
let shadow = TGCornerView(frame: cardWrapperContent.bounds)
@@ -1062,6 +1223,11 @@ extension TGCardViewController {
guard let cardView else { return }
self.updateMapShadow(for: animateTo.position)
+ if self.usesSystemSheet {
+ cardView.alpha = 1
+ oldTop?.view?.alpha = 0
+ return
+ }
cardView.frame = self.cardWrapperContent.bounds
self.cardWrapperEffectView.frame = cardView.frame
self.cardTransitionShadow?.alpha = 0.15
@@ -1081,10 +1247,12 @@ extension TGCardViewController {
self.cardWrapperShadow.frame = oldShadowFrame
}
+ applySheetPosition(animateTo.position, animated: animated)
+
UIView.animate(
- withDuration: animated ? Constants.pushAnimationDuration : 0,
+ withDuration: animated ? (usesSystemSheet ? Constants.sheetCrossFadeDuration : Constants.pushAnimationDuration) : 0,
delay: 0,
- usingSpringWithDamping: 0.75,
+ usingSpringWithDamping: usesSystemSheet ? 1 : 0.75,
initialSpringVelocity: 0,
options: [.curveEaseInOut],
animations: {
@@ -1100,7 +1268,10 @@ extension TGCardViewController {
completion: { _ in
self.updateCardScrolling(allow: animateTo.position == .extended, view: cardView)
self.previousCardPosition = animateTo.position
- oldTop?.view?.alpha = 0
+ if let oldView = oldTop?.view, oldView !== self.topCardView {
+ // Only if it's still covered, i.e., it wasn't popped back to already
+ self.detachCoveredCardView(oldView)
+ }
if notify {
oldTop?.card.didDisappear(animated: animated)
top.didAppear(animated: animated)
@@ -1111,6 +1282,8 @@ extension TGCardViewController {
self.updateForNewPosition(position: animateTo.position)
self.updateResponderChainForNewTopCard()
self.toggleCardWrappers(hide: cardView == nil)
+ self.updateSheetContentScrollView()
+ self.updateSystemSheetPresentation()
if let preferred = top.preferredView {
UIAccessibility.post(notification: .screenChanged, argument: preferred)
}
@@ -1151,6 +1324,8 @@ extension TGCardViewController {
}
isPopping = true
+ isTransitioningCards = true
+ defer { isTransitioningCards = false }
let newTop = cardWithView(atIndex: cards.count - 2)
let topView = topCardView
@@ -1190,7 +1365,13 @@ extension TGCardViewController {
}
// 4. Determine and set new position of the card wrapper (relative to header!)
- newTop?.view?.alpha = 1
+ if let newView = newTop?.view {
+ reattachCardView(newView, below: topView)
+ if animated, usesSystemSheet {
+ newView.alpha = 0 // and cross-fade below
+ }
+ }
+ updateVerticalBarItems()
// We only animate to the previous position if the card obscures the map
updateCardStructure(card: newTop?.view, position: newTop?.lastPosition)
@@ -1199,7 +1380,13 @@ extension TGCardViewController {
if forceExtended || !cardIsNextToMap(in: traitCollection) {
let target = cardLocation(forDesired: forceExtended ? .extended : newTop?.lastPosition, direction: .down)
animateTo = target.position
- mapViewController.additionalSafeAreaInsets = updateCardPosition(y: target.y)
+ if usesSystemSheet {
+ // Like with the classic panner, popping re-enables dragging
+ isSheetDraggingEnabled = true
+ sheetTargetPosition = target.position
+ } else {
+ mapViewController.additionalSafeAreaInsets = updateCardPosition(y: target.y)
+ }
} else {
animateTo = cardPosition
}
@@ -1220,8 +1407,8 @@ extension TGCardViewController {
// 5. Do the transition, optionally animated.
// We animate the view moving back down to the bottom
// we also temporarily insert a shadow view again, if there's a card below
- if animated, cardTransitionShadow == nil, let topView {
- if #available(iOS 26.0, visionOS 26.0, *), let container = cardWrapperShadow.superview, let snapshot = topView.snapshotView(afterScreenUpdates: false) {
+ if animated, cardTransitionShadow == nil, let topView, !usesSystemSheet {
+ if #available(iOS 26.0, visionOS 26.0, *), let container = cardTransitionContainer, let snapshot = topView.snapshotView(afterScreenUpdates: false) {
topView.alpha = 0
var newFrame = topView.bounds
newFrame.origin = topView.convert(topView.bounds.origin, to: container)
@@ -1233,7 +1420,7 @@ extension TGCardViewController {
#endif
visualEffectView.contentView.addSubview(snapshot)
visualEffectView.frame = newFrame
- container.insertSubview(visualEffectView, aboveSubview: cardWrapperShadow)
+ container.insertSubview(visualEffectView, aboveSubview: cardTransitionAnchor)
cardTransitionShadow = visualEffectView
} else {
@@ -1249,14 +1436,17 @@ extension TGCardViewController {
self.toggleCardWrappers(hide: newTop?.view == nil, prepareOnly: true)
self.updateMapShadow(for: animateTo)
- if #unavailable(iOS 26.0) {
+ if self.usesSystemSheet {
+ topView?.alpha = 0
+ newTop?.view?.alpha = 1
+ } else if #unavailable(iOS 26.0) {
self.cardTransitionShadow?.alpha = 0
topView?.frame.origin.y = self.cardWrapperContent.frame.maxY
} else {
self.cardTransitionShadow?.frame.origin.y = self.cardWrapperContent.frame.maxY + 100
}
- newTop?.view?.adjustContentAlpha(to: animateTo == .collapsed ? 0 : 1)
- newTop?.view?.setSeparatorVisibility(forceHidden: animateTo == .collapsed)
+ newTop?.view?.adjustContentAlpha(to: self.contentAlpha(for: animateTo))
+ newTop?.view?.setSeparatorVisibility(forceHidden: self.forcesSeparatorHidden(for: animateTo))
}
if mode != .floating {
@@ -1264,8 +1454,10 @@ extension TGCardViewController {
topView?.alpha = 0
}
+ applySheetPosition(animateTo, animated: animated)
+
UIView.animate(
- withDuration: animated ? Constants.pushAnimationDuration * 1.25 : 0,
+ withDuration: animated ? (usesSystemSheet ? Constants.sheetCrossFadeDuration : Constants.pushAnimationDuration * 1.25) : 0,
delay: 0,
usingSpringWithDamping: 1,
initialSpringVelocity: 0,
@@ -1294,6 +1486,8 @@ extension TGCardViewController {
self.updateResponderChainForNewTopCard()
self.isPopping = false
self.toggleCardWrappers(hide: newTop?.view == nil)
+ self.updateSheetContentScrollView()
+ self.updateSystemSheetPresentation()
if let preferred = newTop?.card.preferredView {
UIAccessibility.post(notification: .screenChanged, argument: preferred)
}
@@ -1357,7 +1551,7 @@ extension TGCardViewController {
extension TGCardViewController {
- fileprivate enum Direction {
+ enum Direction {
case up
case down
@@ -1371,7 +1565,7 @@ extension TGCardViewController {
}
/// - Returns: The map inset to apply to the map view controller. Set it directly or in an animation block.
- private func updateCardPosition(y: CGFloat) -> UIEdgeInsets {
+ func updateCardPosition(y: CGFloat) -> UIEdgeInsets {
// The constraint moves the card into place
cardWrapperDesiredTopConstraint.constant = y
@@ -1436,16 +1630,24 @@ extension TGCardViewController {
topCardView?.layoutIfNeeded()
updateCardStructure(card: topCardView, position: cardPosition)
+
+ // The collapsed sheet shows exactly the title
+ applySheetPosition(animated: true)
}
- private func updateForNewPosition(position: TGCardPosition) {
+ func updateForNewPosition(position: TGCardPosition) {
previousCardPosition = position
topCardView?.grabHandles.forEach {
- updateCardHandleAccessibility(handle: $0, position: position)
+ if usesSystemSheet {
+ // The sheet's grabber takes over
+ $0.isAccessibilityElement = false
+ } else {
+ updateCardHandleAccessibility(handle: $0, position: position)
+ }
}
- let mapIsInteractive = cardIsNextToMap(in: traitCollection) || position != .extended
+ let mapIsInteractive = usesSystemSheet || cardIsNextToMap(in: traitCollection) || position != .extended
mapViewController.isUserInteractionEnabled = mapIsInteractive
topFloatingViewWrapper.isUserInteractionEnabled = mapIsInteractive
bottomFloatingViewWrapper.isUserInteractionEnabled = mapIsInteractive
@@ -1549,7 +1751,7 @@ extension TGCardViewController {
@objc
fileprivate func handlePan(_ recogniser: UIPanGestureRecognizer) {
- guard mode == .floating else { return }
+ guard mode == .floating, !usesSystemSheet else { return }
// Reset dragger state if we aren't currently moving, but ALSO not if it
// just ended, we don't want to exit early and still snap then. We reset
@@ -1636,7 +1838,7 @@ extension TGCardViewController {
@objc
fileprivate func handleMapTap(_ recogniser: UITapGestureRecognizer) {
- guard mode == .floating, cardPosition == .extended, topCard?.mapManager != nil else { return }
+ guard mode == .floating, !usesSystemSheet, cardPosition == .extended, topCard?.mapManager != nil else { return }
switchTo(.peaking, direction: .down, animated: true)
}
@@ -1645,6 +1847,7 @@ extension TGCardViewController {
fileprivate func handleInnerPan(_ recogniser: UIPanGestureRecognizer) {
guard
mode == .floating,
+ !usesSystemSheet,
let scrollView = recogniser.view as? UIScrollView,
scrollView == topCardView?.contentScrollView,
panner.isEnabled,
@@ -1734,6 +1937,19 @@ extension TGCardViewController {
let animateTo = cardLocation(forDesired: position, direction: direction)
+ if usesSystemSheet {
+ UIView.animate(withDuration: animated ? Constants.tapAnimationDuration : 0) {
+ self.updateFloatingViewsVisibility(for: animateTo.position)
+ }
+ applySheetPosition(animateTo.position, animated: animated) {
+ self.topCard?.mapManager?.edgePadding = self.mapEdgePadding(for: animateTo.position)
+ self.topCard?.didMove(to: animateTo.position, animated: animated)
+ self.updateForNewPosition(position: animateTo.position)
+ handler?()
+ }
+ return
+ }
+
let mapInsets = updateCardPosition(y: animateTo.y)
view.setNeedsUpdateConstraints()
@@ -1758,9 +1974,9 @@ extension TGCardViewController {
})
}
- private func updatePannerInteractivity(for cardElement:
+ func updatePannerInteractivity(for cardElement:
(card: TGCard, lastPosition: TGCardPosition, view: TGCardView?)? = nil) {
- guard panningAllowed, mode == .floating else { return }
+ guard panningAllowed, mode == .floating, !usesSystemSheet else { return }
let card = cardElement?.card ?? topCard
let isForceExtended = card?.mapManager == nil
panner.isEnabled = !isForceExtended
@@ -1768,15 +1984,52 @@ extension TGCardViewController {
}
+// MARK: - Covered cards
+
+extension TGCardViewController {
+
+ /// Takes the view of a card that's covered by another card out of the view
+ /// hierarchy, so that it doesn't take part in layout, rendering or
+ /// accessibility while it can't be seen.
+ ///
+ /// The view stays alive, as `cards` holds on to it, so it keeps its state, such
+ /// as its scroll position, and gets put back by `reattachCardView(_:below:)`
+ /// when the card above it gets popped.
+ func detachCoveredCardView(_ cardView: TGCardView) {
+ cardView.alpha = 0
+ cardView.removeFromSuperview()
+ }
+
+ /// Puts the view of a card that's about to be revealed back into the view
+ /// hierarchy, underneath the card that's about to go away.
+ func reattachCardView(_ cardView: TGCardView, below topView: UIView?) {
+ if cardView.superview !== cardWrapperContent {
+ UIView.performWithoutAnimation {
+ // The card wrapper might have changed size in the meantime
+ cardView.frame = cardWrapperContent.bounds
+ if let topView, topView.superview === cardWrapperContent {
+ cardWrapperContent.insertSubview(cardView, belowSubview: topView)
+ } else {
+ cardWrapperContent.addSubview(cardView)
+ }
+ cardView.layoutIfNeeded()
+ }
+ }
+ cardView.alpha = 1
+ }
+
+}
+
// MARK: - Grab handle
extension TGCardViewController {
- private func updateGrabHandleVisibility(for cardElement:
+ func updateGrabHandleVisibility(for cardElement:
(card: TGCard, lastPosition: TGCardPosition, view: TGCardView?)? = nil) {
let card = cardElement?.card ?? topCard
let view = cardElement?.view ?? topCardView
- let isForceExtended = card?.mapManager == nil || mode == .sidebar
+ // A system sheet has its own grabber
+ let isForceExtended = card?.mapManager == nil || mode == .sidebar || usesSystemSheet
view?.grabHandles.forEach { $0.alpha = isForceExtended ? 0 : 1 }
}
}
@@ -1848,9 +2101,15 @@ extension TGCardViewController {
UIView.animate(withDuration: animated ? 0.25 : 0) {
// hide the card and disable all card-based interaction
- self.panner.isEnabled = show
- self.cardWrapperShadow?.isUserInteractionEnabled = show
- self.cardWrapperShadow?.alpha = show ? 1 : 0
+ if let sheetHost = self.sheetHost {
+ sheetHost.presentationController?.containerView?.alpha = show ? 1 : 0
+ self.mapVerticalBarItems?.alpha = show ? 1 : 0
+ self.mapVerticalBarItems?.isUserInteractionEnabled = show
+ } else {
+ self.panner.isEnabled = show
+ self.cardWrapperShadow?.isUserInteractionEnabled = show
+ self.cardWrapperShadow?.alpha = show ? 1 : 0
+ }
// update the map shadow; have to hide it in `extended`
self.updateMapShadow(for: show ? self.cardPosition : .collapsed)
@@ -1885,7 +2144,7 @@ extension TGCardViewController {
}
}
- private func updateFloatingViewsVisibility(for position: TGCardPosition? = nil, animated: Bool = false) {
+ func updateFloatingViewsVisibility(for position: TGCardPosition? = nil, animated: Bool = false) {
let fade: Bool
if !allowFloatingViews {
fade = true
@@ -2018,13 +2277,13 @@ extension TGCardViewController {
/// down again otherwise — so a card without items carries none of the
/// affordance's layout. Called both during the full floating-view refresh and
/// right after `didBuild`, since cards commonly configure their items there.
- private func updateCardFloatingViewContent(card: TGCard?) {
+ func updateCardFloatingViewContent(card: TGCard?) {
if let cardItems = card?.floatingCardToolBarItems, !cardItems.isEmpty {
installCardFloatingViewIfNeeded()
populateFloatingView(cardFloatingView, with: cardItems)
applyCardFloatingAlignment(card?.floatingCardToolBarAlignment ?? .center)
cardFloatingView.isHidden = false
- view.bringSubviewToFront(cardFloatingView)
+ cardFloatingView.superview?.bringSubviewToFront(cardFloatingView)
} else {
cleanUpFloatingView(cardFloatingView)
cardFloatingView.isHidden = true
@@ -2034,7 +2293,7 @@ extension TGCardViewController {
}
}
- private func updateFloatingViewsConstraints() {
+ func updateFloatingViewsConstraints() {
if cardIsNextToMap(in: traitCollection) {
bottomFloatingViewBottomConstraint.constant = deviceIsiPhoneX() ? 0 : 8
bottomFloatingViewTrailingToSafeAreaConstraint.constant = deviceIsiPhoneX() ? 0 : 8
@@ -2046,6 +2305,11 @@ extension TGCardViewController {
topFloatingViewTrailingToSafeAreaConstraint.constant = 8
topFloatingViewTopConstraint.constant = 8
}
+
+ if bottomFloatingViewIgnoresVerticalBar {
+ let trailingInset = view.effectiveUserInterfaceLayoutDirection == .rightToLeft ? view.safeAreaInsets.left : view.safeAreaInsets.right
+ bottomFloatingViewTrailingToSafeAreaConstraint.constant -= trailingInset
+ }
}
private func populateFloatingView(_ floatingView: UIStackView, with views: [UIView]) {
@@ -2081,7 +2345,7 @@ extension TGCardViewController {
extension TGCardViewController {
- fileprivate var isShowingHeader: Bool {
+ var isShowingHeader: Bool {
return headerViewTopConstraint.constant > -1
}
@@ -2108,6 +2372,7 @@ extension TGCardViewController {
headerView.subviews
.compactMap { $0 as? TGHeaderView }
.forEach { applyCornerStyle(to: $0) }
+ updateHeaderForSheet()
updateStatusBar(headerIsVisible: isShowingHeader)
@@ -2130,7 +2395,9 @@ extension TGCardViewController {
}
private func adjustHeaderPositioningConstraint() {
- if cardIsNextToMap(in: traitCollection) {
+ if headerFloatsAboveSheet {
+ headerViewTopConstraint.constant = topOverlap + headerSheetGap
+ } else if cardIsNextToMap(in: traitCollection) {
headerViewTopConstraint.constant = topOverlap + Constants.floatingHeaderTopMargin
} else {
headerViewTopConstraint.constant = 0
@@ -2139,7 +2406,11 @@ extension TGCardViewController {
private func adjustHeaderHeightConstraint(toFit content: UIView) {
let contentSize = content.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
- if cardIsNextToMap(in: traitCollection) {
+ if headerSpansTopAboveSheet {
+ headerViewHeightConstraint.constant = contentSize.height + topOverlap + 2 * Constants.sheetHeaderContentInset
+ } else if headerFloatsAboveSheet {
+ headerViewHeightConstraint.constant = contentSize.height + 2 * Constants.sheetHeaderContentInset
+ } else if cardIsNextToMap(in: traitCollection) {
headerViewHeightConstraint.constant = contentSize.height
} else {
headerViewHeightConstraint.constant = contentSize.height + topOverlap
@@ -2157,6 +2428,11 @@ extension TGCardViewController {
// notify UIKit the header's contraints need to be updated.
view.setNeedsUpdateConstraints()
+
+ // An extended sheet needs to make space for the header
+ if !isTransitioningCards {
+ applySheetPosition(animated: animated)
+ }
// animate in
let spring = cardIsNextToMap(in: traitCollection)
@@ -2177,6 +2453,9 @@ extension TGCardViewController {
fileprivate func hideHeader(animated: Bool) {
headerViewTopConstraint.constant = headerView.frame.height * -1
view.setNeedsUpdateConstraints()
+ if !isTransitioningCards {
+ applySheetPosition(animated: animated)
+ }
guard animated else {
self.view.layoutIfNeeded()
@@ -2208,9 +2487,131 @@ extension TGCardViewController {
headerView.addSubview(content)
let contentSize = content.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
content.heightAnchor.constraint(equalToConstant: contentSize.height).isActive = true
- content.leadingAnchor.constraint(equalTo: headerView.leadingAnchor).isActive = true
- content.trailingAnchor.constraint(equalTo: headerView.trailingAnchor).isActive = true
- content.bottomAnchor.constraint(equalTo: headerView.bottomAnchor).isActive = true
+ let inset = usesSystemSheet ? Constants.sheetHeaderContentInset : 0
+ content.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: inset).isActive = true
+ headerView.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: inset).isActive = true
+ headerView.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: inset).isActive = true
+ }
+
+ /// Whether the header spans the top of the screen above a system sheet, i.e.,
+ /// in compact width, rather than floating above a card next to the map
+ var headerSpansTopAboveSheet: Bool {
+ usesSystemSheet && !cardIsNextToMap(in: traitCollection)
+ }
+
+ /// Above a system sheet, the header spans the full width of the screen, with
+ /// square corners, like a toolbar; its content keeps clear of the safe area,
+ /// e.g., of a vertical bar.
+ /// Above a system sheet, the header either spans the full width of the
+ /// screen with square corners, like a toolbar, in compact width; or it
+ /// floats right above the sheet, aligned with it, when the sheet is on the
+ /// side of the screen. Its content keeps clear of the safe area, e.g., of a
+ /// vertical bar.
+ private func updateHeaderForSheet() {
+ guard let superview = headerView.superview else { return }
+ let spansTop = headerSpansTopAboveSheet
+ let floatsAboveSheet = headerFloatsAboveSheet
+
+ if headerCardEdgeConstraints.isEmpty {
+ let horizontal: [NSLayoutConstraint.Attribute] = [.leading, .trailing]
+ headerCardEdgeConstraints = superview.constraints.filter {
+ $0.firstItem === headerView && $0.secondItem === cardWrapperShadow
+ && horizontal.contains($0.firstAttribute)
+ }
+ headerScreenEdgeConstraints = [
+ headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ ]
+ headerSheetAlignedConstraints = [
+ headerView.leftAnchor.constraint(equalTo: view.leftAnchor),
+ headerView.widthAnchor.constraint(equalToConstant: 0),
+ ]
+ headerEffectToScreenTopConstraints = superview.constraints.filter {
+ $0.firstItem === headerEffectView && $0.firstAttribute == .top && $0.secondItem !== headerView
+ }
+ headerEffectToHeaderTopConstraints = [
+ headerEffectView.topAnchor.constraint(equalTo: headerView.topAnchor),
+ ]
+ }
+
+ let horizontal = spansTop ? headerScreenEdgeConstraints
+ : floatsAboveSheet ? headerSheetAlignedConstraints
+ : headerCardEdgeConstraints
+ NSLayoutConstraint.deactivate(
+ [headerScreenEdgeConstraints, headerSheetAlignedConstraints, headerCardEdgeConstraints]
+ .flatMap { $0 }
+ .filter { !horizontal.contains($0) }
+ )
+ NSLayoutConstraint.activate(horizontal)
+
+ if floatsAboveSheet {
+ NSLayoutConstraint.deactivate(headerEffectToScreenTopConstraints)
+ NSLayoutConstraint.activate(headerEffectToHeaderTopConstraints)
+ } else {
+ NSLayoutConstraint.deactivate(headerEffectToHeaderTopConstraints)
+ NSLayoutConstraint.activate(headerEffectToScreenTopConstraints)
+ }
+
+ if spansTop {
+ headerView.layer.cornerRadius = 0
+ headerView.subviews
+ .compactMap { $0 as? TGHeaderView }
+ .forEach { $0.layer.cornerRadius = 0 }
+ }
+
+#if !os(visionOS)
+ if #available(iOS 26.0, *) {
+ let radius = UICornerRadius.fixed(spansTop ? 0 : Constants.iOS26CornerRadius)
+ // Floating near the top of the screen, follow its rounded corners
+ let topRadius: UICornerRadius? = floatsAboveSheet
+ ? .containerConcentric(minimum: Constants.iOS26CornerRadius)
+ : nil
+ headerEffectView.cornerConfiguration = .corners(
+ topLeftRadius: topRadius,
+ topRightRadius: topRadius,
+ bottomLeftRadius: radius,
+ bottomRightRadius: radius
+ )
+ }
+#endif
+ matchHeaderBackgroundCorners()
+ }
+
+ /// Whether the header floats right above a system sheet that's on the side
+ /// of the screen, e.g., on iPad
+ var headerFloatsAboveSheet: Bool {
+ usesSystemSheet && cardIsNextToMap(in: traitCollection)
+ }
+
+ /// Aligns a header that floats above the system sheet with the sheet, and
+ /// keeps the same distance to the top as the sheet does to the side
+ ///
+ /// - Parameter sheetFrame: The sheet's frame in this controller's view
+ func alignHeader(withSheetFrame sheetFrame: CGRect) {
+ guard headerFloatsAboveSheet, headerSheetAlignedConstraints.count == 2 else { return }
+
+ let gap: CGFloat
+ if sheetFrame.midX < view.bounds.midX {
+ gap = sheetFrame.minX - view.safeAreaInsets.left
+ } else {
+ gap = view.bounds.maxX - view.safeAreaInsets.right - sheetFrame.maxX
+ }
+ var changed = false
+ if abs(headerSheetAlignedConstraints[0].constant - sheetFrame.minX) > 0.5 {
+ headerSheetAlignedConstraints[0].constant = sheetFrame.minX
+ changed = true
+ }
+ if abs(headerSheetAlignedConstraints[1].constant - sheetFrame.width) > 0.5 {
+ headerSheetAlignedConstraints[1].constant = sheetFrame.width
+ changed = true
+ }
+ if abs(headerSheetGap - max(0, gap)) > 0.5 {
+ headerSheetGap = max(0, gap)
+ changed = true
+ }
+ if changed {
+ updateHeaderConstraints()
+ }
}
private func updateStatusBar(headerIsVisible: Bool, preferredStyle: UIStatusBarStyle? = nil) {
@@ -2351,6 +2752,9 @@ extension TGCardViewController: TGCardDelegate {
}
public func contentScrollViewDidChange(old: UIScrollView?, for card: TGCard) {
+ if card === topCard {
+ updateSheetContentScrollView()
+ }
guard panningAllowed, card === topCard, let view = topCardView else { return }
old?.panGestureRecognizer.removeTarget(self, action: nil)
@@ -2514,7 +2918,7 @@ extension TGCardViewController {
),
]
- if presentedViewController != nil {
+ if presentedOverlayViewController != nil {
#if targetEnvironment(macCatalyst)
commands.append(
UIKeyCommand(
diff --git a/Sources/TGCardViewController/TGSheetHostViewController.swift b/Sources/TGCardViewController/TGSheetHostViewController.swift
new file mode 100644
index 0000000..1ac2076
--- /dev/null
+++ b/Sources/TGCardViewController/TGSheetHostViewController.swift
@@ -0,0 +1,183 @@
+//
+// TGSheetHostViewController.swift
+// TGCardViewController
+//
+// Created by Adrian Schoenig on 9/10/2026.
+// Copyright © 2026 SkedGo Pty Ltd. All rights reserved.
+//
+
+import UIKit
+
+/// Hosts the card stack in a system sheet, when ``TGCardViewController`` presents
+/// its cards in one, see ``TGCardViewController/PresentationStyle-swift.enum/automatic``.
+///
+/// The card controller stays in charge of the cards, the map and the header. This
+/// controller only provides the sheet's view, forwards the sheet's callbacks, and
+/// makes sure that only the card controller can dismiss the sheet. Presentations
+/// and dismissals that reach this controller are routed back through the card
+/// controller, so that subclasses overriding `present` or `dismiss` on the card
+/// controller see them, too.
+@MainActor
+final class TGSheetHostViewController: UIViewController {
+
+ private(set) weak var cardController: TGCardViewController?
+
+ /// Set by the card controller while it dismisses the sheet itself.
+ var allowsDismissingSheet = false
+
+ /// Whether something presented on top of the sheet covers the screen
+ private var isCoveredByPresentation = false
+
+ init(cardController: TGCardViewController) {
+ self.cardController = cardController
+ super.init(nibName: nil, bundle: nil)
+ modalPresentationStyle = .pageSheet
+ isModalInPresentation = true
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func loadView() {
+ let view = UIView()
+ view.backgroundColor = .clear
+ view.clipsToBounds = true
+ if #available(iOS 26.0, visionOS 26.0, *) {
+ // The sheet rounds its background but doesn't clip what's in it, so cards
+ // with opaque content would stick out at its corners.
+ view.cornerConfiguration = .corners(radius: .containerConcentric())
+ }
+ self.view = view
+ }
+
+ // The card controller is the presenting view controller, but make that explicit,
+ // so that anything in the sheet that isn't part of a card's own responder chain
+ // still reaches the card controller's key commands and actions.
+ override var next: UIResponder? {
+ cardController ?? super.next
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+#if compiler(>=6.4) && os(iOS) && !targetEnvironment(macCatalyst) // Xcode 27 proxy; vertical bars are iPhone and iPad only
+ if #available(iOS 27.1, *) {
+ // The close button follows the vertical bar, e.g., when folding an
+ // iPhone Duo
+ registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) { (host: TGSheetHostViewController, _: UITraitCollection) in
+ host.cardController?.updateVerticalBarItems()
+ }
+ }
+#endif
+ }
+
+ // MARK: - Appearance
+
+ // Something presented full screen on top of the sheet would have made the card
+ // controller disappear, if it had presented it itself. Tell it, so that it, its
+ // subclasses and its cards get the same callbacks with and without the sheet.
+
+ override func viewWillDisappear(_ animated: Bool) {
+ super.viewWillDisappear(animated)
+
+ if presentedViewController != nil, !isBeingDismissed, let cardController {
+ isCoveredByPresentation = true
+ cardController.beginAppearanceTransition(false, animated: animated)
+ }
+ }
+
+ override func viewDidDisappear(_ animated: Bool) {
+ super.viewDidDisappear(animated)
+
+ if isCoveredByPresentation {
+ cardController?.endAppearanceTransition()
+ }
+ }
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+
+ if isCoveredByPresentation {
+ cardController?.beginAppearanceTransition(true, animated: animated)
+ }
+ }
+
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+
+ if isCoveredByPresentation {
+ isCoveredByPresentation = false
+ cardController?.endAppearanceTransition()
+ }
+
+ // Only now is it in its final place, which determines the vertical bar
+ cardController?.updateVerticalBarItems()
+ }
+
+ // MARK: - Layout
+
+ override func viewDidLayoutSubviews() {
+ super.viewDidLayoutSubviews()
+
+ cardController?.sheetHostDidLayoutSubviews()
+ }
+
+ override func accessibilityPerformEscape() -> Bool {
+ cardController?.popMaybe() ?? false
+ }
+
+ // MARK: - Presenting and dismissing
+
+ override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
+ if let cardController {
+ // Ends up in `presentDirectly` via the card controller's routing
+ cardController.present(viewControllerToPresent, animated: flag, completion: completion)
+ } else {
+ super.present(viewControllerToPresent, animated: flag, completion: completion)
+ }
+ }
+
+ override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
+ if allowsDismissingSheet {
+ super.dismiss(animated: flag, completion: completion)
+ } else if let cardController {
+ // Ends up in `dismissPresented` via the card controller's routing
+ cardController.dismiss(animated: flag, completion: completion)
+ } else {
+ super.dismiss(animated: flag, completion: completion)
+ }
+ }
+
+ /// Presents on top of the sheet, without routing through the card controller.
+ func presentDirectly(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)?) {
+ super.present(viewControllerToPresent, animated: flag, completion: completion)
+ }
+
+ /// Dismisses whatever is presented on top of the sheet, but never the sheet itself.
+ func dismissPresented(animated flag: Bool, completion: (() -> Void)?) {
+ guard presentedViewController != nil else {
+ completion?()
+ return
+ }
+ super.dismiss(animated: flag, completion: completion)
+ }
+
+}
+
+// MARK: - UISheetPresentationControllerDelegate
+
+extension TGSheetHostViewController: UISheetPresentationControllerDelegate {
+
+#if !os(visionOS) // No detents on visionOS, where cards never use a sheet
+ func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) {
+ cardController?.sheetDidChangeSelectedDetent()
+ }
+#endif
+
+ func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
+ false
+ }
+
+}
diff --git a/Sources/TGCardViewController/cards/TGCard.swift b/Sources/TGCardViewController/cards/TGCard.swift
index 776bec6..64d64cd 100644
--- a/Sources/TGCardViewController/cards/TGCard.swift
+++ b/Sources/TGCardViewController/cards/TGCard.swift
@@ -252,6 +252,32 @@ open class TGCard: UIResponder, TGPreferrableView {
///
/// - SeeAlso: `floatingCardToolBarItems`
public var floatingCardToolBarAlignment: FloatingCardToolBarAlignment = .center
+
+ /// Secondary actions of the card, e.g., for sharing or adding a favourite,
+ /// which the card controller can show in a vertical bar.
+ ///
+ /// On devices with a vertical bar, e.g., the iPhone Duo's outer display, and
+ /// while the cards are in a system sheet, the top card's vertical bar
+ /// actions show as icon-only buttons at the bottom of that bar, like Mail's.
+ /// Otherwise they're not shown, so the card should then show them itself;
+ /// `showsVerticalBarActions` says which applies.
+ ///
+ /// The actions' titles are used as the buttons' accessibility labels. To
+ /// update a button, e.g., when toggling a favourite, set new actions.
+ ///
+ /// For a `TGPageCard`, the bar shows its own actions followed by those of
+ /// the current page.
+ public var verticalBarActions: [UIAction] = [] {
+ didSet { controller?.updateVerticalBarItems() }
+ }
+
+ /// Whether the card controller shows the card's `verticalBarActions` in a
+ /// vertical bar, while the card is on top. If so, the card shouldn't show
+ /// them itself.
+ ///
+ /// Supports key-value observing, as this can change while the card is
+ /// showing, e.g., when rotating the device.
+ @objc public internal(set) dynamic var showsVerticalBarActions: Bool = false
/// Builds the card's optional header which will be pinned to the top
///
diff --git a/Sources/TGCardViewController/cards/TGCardStyle.swift b/Sources/TGCardViewController/cards/TGCardStyle.swift
index 6ff5324..b920ae3 100644
--- a/Sources/TGCardViewController/cards/TGCardStyle.swift
+++ b/Sources/TGCardViewController/cards/TGCardStyle.swift
@@ -43,6 +43,19 @@ public struct TGCardStyle {
}
}()
+ /// Colour to fade in behind the card in a system sheet, as the sheet moves
+ /// up from collapsed, and which fully shows when it's peaking or extended.
+ /// Defaults to `nil`, i.e., none.
+ ///
+ /// Use this when `backgroundColor` is clear, so that the content of a raised
+ /// card sits on a calm background rather than on whatever shines through
+ /// the sheet's glass from the map, while a collapsed sheet still shows its
+ /// material.
+ ///
+ /// - Note: Only used when the cards show in a system sheet; the classic card
+ /// keeps its own look.
+ public var expandedBackgroundColor: UIColor? = nil
+
/// Colour to use for the grab handle on the card, defaults to system secondary label color
public var grabHandleColor: UIColor = .secondaryLabel
diff --git a/Sources/TGCardViewController/cards/TGCardView.swift b/Sources/TGCardViewController/cards/TGCardView.swift
index 21e97ed..fcdae9e 100644
--- a/Sources/TGCardViewController/cards/TGCardView.swift
+++ b/Sources/TGCardViewController/cards/TGCardView.swift
@@ -102,6 +102,62 @@ public class TGCardView: TGCornerView, TGPreferrableView {
contentSeparator?.backgroundColor = .opaqueSeparator
}
+ override public func didMoveToWindow() {
+ super.didMoveToWindow()
+
+ // In a system sheet, cards span the sheet's full width, so that the
+ // backgrounds of their contents and their scroll indicators can extend
+ // under a vertical bar. The title and content then stay in the safe area,
+ // as do the contents of the cells.
+ let isInSheet = sequence(first: self as UIView, next: \.superview)
+ .contains { $0.next is TGSheetHostViewController }
+ keepsContentInSafeArea = isInSheet
+ }
+
+ // MARK: - Safe area
+
+ /// Whether the title and content stay within the horizontal safe area,
+ /// rather than spanning the card's full width
+ private(set) var keepsContentInSafeArea = false {
+ didSet {
+ guard keepsContentInSafeArea != oldValue else { return }
+ updateSafeAreaConstraints()
+ }
+ }
+
+ /// Called when `keepsContentInSafeArea` changes; subclasses with more content
+ /// to keep in the safe area override this and call `super`
+ func updateSafeAreaConstraints() {
+ updateTitleSafeAreaConstraints()
+ }
+
+ private var titleEdgeConstraints: [NSLayoutConstraint] = []
+ private var titleSafeAreaConstraints: [NSLayoutConstraint] = []
+
+ private func updateTitleSafeAreaConstraints() {
+ guard let placeholder = titleViewPlaceholder, placeholder.superview === self else { return }
+
+ if titleEdgeConstraints.isEmpty {
+ let horizontal: [NSLayoutConstraint.Attribute] = [.leading, .trailing, .left, .right]
+ titleEdgeConstraints = constraints.filter {
+ ($0.firstItem === placeholder && $0.secondItem === self && horizontal.contains($0.firstAttribute))
+ || ($0.secondItem === placeholder && $0.firstItem === self && horizontal.contains($0.secondAttribute))
+ }
+ titleSafeAreaConstraints = [
+ placeholder.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor),
+ safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: placeholder.trailingAnchor),
+ ]
+ }
+
+ if keepsContentInSafeArea {
+ NSLayoutConstraint.deactivate(titleEdgeConstraints)
+ NSLayoutConstraint.activate(titleSafeAreaConstraints)
+ } else {
+ NSLayoutConstraint.deactivate(titleSafeAreaConstraints)
+ NSLayoutConstraint.activate(titleEdgeConstraints)
+ }
+ }
+
// MARK: - Content Configuration
/// Updates the title and subtitle, when the `.default` title was used.
@@ -228,6 +284,53 @@ public class TGCardView: TGCornerView, TGPreferrableView {
}
}
+ /// The height of the card when collapsed in a system sheet: down to the end
+ /// of the title's content, plus as much space as there is above it, where
+ /// the sheet shows its grabber.
+ ///
+ /// This looks at what the title draws, rather than at its bounds, as title
+ /// views can have different margins at their top and bottom, e.g., to sit
+ /// close to a grab handle, or to leave room before the content.
+ func sheetCollapsedHeight() -> CGFloat {
+ let header = headerHeight(for: .collapsed)
+ guard let titleView else { return header }
+
+ let content = Self.drawnFrame(of: titleView, in: self)
+ guard
+ !content.isNull,
+ content.minY >= 0,
+ content.maxY <= header + 0.5
+ else { return header }
+
+ return content.maxY + content.minY
+ }
+
+ /// The frame of what a view and its subviews draw, ignoring empty space
+ /// of container views
+ private static func drawnFrame(of view: UIView, in target: UIView) -> CGRect {
+ guard !view.isHidden, view.alpha > 0.01, !view.bounds.isEmpty else { return .null }
+
+ let drawsItself: Bool
+ switch view {
+ case let label as UILabel:
+ drawsItself = !(label.text ?? "").isEmpty || label.attributedText.map { $0.length > 0 } ?? false
+ case let imageView as UIImageView:
+ drawsItself = imageView.image != nil
+ case is UIControl:
+ drawsItself = true
+ default:
+ drawsItself = (view.backgroundColor.map { $0.cgColor.alpha > 0 } ?? false)
+ || view.layer.borderWidth > 0
+ }
+ if drawsItself {
+ return view.convert(view.bounds, to: target)
+ }
+
+ return view.subviews.reduce(CGRect.null) {
+ $0.union(drawnFrame(of: $1, in: target))
+ }
+ }
+
// MARK: - Managing Appearance
func applyStyling(_ style: TGCardStyle) {
diff --git a/Sources/TGCardViewController/cards/TGPageCardView.swift b/Sources/TGCardViewController/cards/TGPageCardView.swift
index 0367bce..ef245f9 100644
--- a/Sources/TGCardViewController/cards/TGPageCardView.swift
+++ b/Sources/TGCardViewController/cards/TGPageCardView.swift
@@ -35,9 +35,13 @@ class TGPageCardView: TGCardView {
weak var delegate: TGPageCardViewDelegate?
+ /// Hosts each page in its own view controller, ordered as provided, i.e.,
+ /// logically first one first; not by how it appears.
+ private var pageHosts: [TGPageHostViewController] = []
+
/// The card views, ordered as provided, i.e., logically first one first; not by how it appears.
var cardViews: [TGCardView] {
- (contentView.subviews as? [TGCardView]) ?? []
+ pageHosts.map(\.cardView)
}
private var pageCount: Int {
@@ -50,6 +54,56 @@ class TGPageCardView: TGCardView {
return cardViews[index]
}
+ override func updateSafeAreaConstraints() {
+ super.updateSafeAreaConstraints()
+
+ // The pages keep their contents in the safe area themselves, so the pager
+ // mustn't shift them
+ pager.contentInsetAdjustmentBehavior = keepsContentInSafeArea ? .never : .automatic
+ passSafeAreaToPages()
+ }
+
+ override func safeAreaInsetsDidChange() {
+ super.safeAreaInsetsDidChange()
+ passSafeAreaToPages()
+ }
+
+ /// The page hosts get the safe area of the system, e.g., of a vertical bar
+ /// on the trailing edge, but not what a parent view controller adds, e.g.,
+ /// the sheet for a vertical bar on the leading edge. So tell them what they
+ /// should have, and they make up the difference.
+ private func passSafeAreaToPages() {
+ let insets = keepsContentInSafeArea
+ ? UIEdgeInsets(top: 0, left: safeAreaInsets.left, bottom: 0, right: safeAreaInsets.right)
+ : .zero
+ pageHosts.forEach { $0.horizontalSafeArea = insets }
+ }
+
+ override func didMoveToWindow() {
+ super.didMoveToWindow()
+
+ // UIKit only passes the safe area and layout margins of the pager on to
+ // its content at the edges of that content, so all but the first and last
+ // page would miss them. View controllers get them from their position on
+ // screen instead, so each page is hosted in a child view controller of
+ // whichever view controller this card is in, e.g., the card controller or
+ // the sheet.
+ let parent = window == nil ? nil : sequence(first: self as UIView, next: \.superview)
+ .lazy.compactMap { $0.next as? UIViewController }
+ .first
+ passSafeAreaToPages()
+ for host in pageHosts where host.parent !== parent {
+ if host.parent != nil {
+ host.willMove(toParent: nil)
+ host.removeFromParent()
+ }
+ if let parent {
+ parent.addChild(host)
+ host.didMove(toParent: parent)
+ }
+ }
+ }
+
private var topView: UIView?
override var preferredView: UIView? {
topView ?? self
@@ -64,9 +118,9 @@ class TGPageCardView: TGCardView {
var currentContent: UIView?
- for aContent in contentView.subviews {
- if aContent.frame.contains(pager.contentOffset) {
- currentContent = aContent
+ for host in pageHosts {
+ if host.view.frame.contains(pager.contentOffset) {
+ currentContent = host.cardView
}
}
@@ -123,7 +177,7 @@ class TGPageCardView: TGCardView {
// layout the child card when it's becoming visible soon.
// See: https://gitlab.com/SkedGo/tripgo-cards-ios/issues/3
- let contents = pageCard.cards.map { card -> UIView in
+ let contents = pageCard.cards.map { card -> TGCardView in
guard let view = card.buildCardView() else {
preconditionFailure("Can only include cards in a page view that have a card!")
}
@@ -137,7 +191,8 @@ class TGPageCardView: TGCardView {
return view
}
- fill(with: contents)
+ pageHosts = contents.map(TGPageHostViewController.init(cardView:))
+ fill(with: pageHosts.map(\.view))
// Page card doesn't always start with page 0. So we keep a reference
// to the first page index, which can then be used at a later point.
@@ -365,3 +420,79 @@ extension TGPageCardView: UIScrollViewDelegate {
}
}
+
+// MARK: -
+
+/// Hosts a page of a page card, so that it gets the safe area and layout
+/// margins of where it is on screen
+private final class TGPageHostViewController: UIViewController {
+
+ let cardView: TGCardView
+
+ /// The horizontal safe area that the page should have, see
+ /// `TGPageCardView.passSafeAreaToPages`
+ var horizontalSafeArea: UIEdgeInsets = .zero {
+ didSet {
+ guard horizontalSafeArea != oldValue else { return }
+ updateAdditionalSafeArea()
+ }
+ }
+
+ /// Set while UIKit hasn't applied the last change of the additional safe
+ /// area yet, during which the safe area doesn't reflect it
+ private var isAwaitingSafeArea = false
+
+ init(cardView: TGCardView) {
+ self.cardView = cardView
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func loadView() {
+ let view = UIView()
+ view.backgroundColor = .clear
+ cardView.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(cardView)
+ NSLayoutConstraint.activate([
+ cardView.topAnchor.constraint(equalTo: view.topAnchor),
+ cardView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ view.trailingAnchor.constraint(equalTo: cardView.trailingAnchor),
+ view.bottomAnchor.constraint(equalTo: cardView.bottomAnchor),
+ ])
+ self.view = view
+ }
+
+ override func viewSafeAreaInsetsDidChange() {
+ super.viewSafeAreaInsetsDidChange()
+ isAwaitingSafeArea = false
+ updateAdditionalSafeArea()
+ }
+
+ override func viewDidLayoutSubviews() {
+ super.viewDidLayoutSubviews()
+ isAwaitingSafeArea = false
+ }
+
+ private func updateAdditionalSafeArea() {
+ guard isViewLoaded, !isAwaitingSafeArea else { return }
+
+ // What UIKit passes on, without what we add
+ let safe = view.safeAreaInsets
+ let inheritedLeft = safe.left - additionalSafeAreaInsets.left
+ let inheritedRight = safe.right - additionalSafeAreaInsets.right
+
+ var additional = additionalSafeAreaInsets
+ additional.left = max(0, horizontalSafeArea.left - inheritedLeft)
+ additional.right = max(0, horizontalSafeArea.right - inheritedRight)
+ guard abs(additional.left - additionalSafeAreaInsets.left) > 0.5
+ || abs(additional.right - additionalSafeAreaInsets.right) > 0.5
+ else { return }
+
+ isAwaitingSafeArea = true
+ additionalSafeAreaInsets = additional
+ }
+
+}
diff --git a/Sources/TGCardViewController/cards/TGPlainCardView.swift b/Sources/TGCardViewController/cards/TGPlainCardView.swift
index de4bc6a..35a451e 100644
--- a/Sources/TGCardViewController/cards/TGPlainCardView.swift
+++ b/Sources/TGCardViewController/cards/TGPlainCardView.swift
@@ -48,11 +48,35 @@ class TGPlainCardView: TGCardView {
if let content = contentView {
content.translatesAutoresizingMaskIntoConstraints = false
self.contentView.addSubview(content)
- content.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor).isActive = true
content.topAnchor.constraint(equalTo: self.contentView.topAnchor).isActive = true
- self.contentView.trailingAnchor.constraint(equalTo: content.trailingAnchor).isActive = true
self.contentView.bottomAnchor.constraint(equalTo: content.bottomAnchor).isActive = true
+ contentEdgeConstraints = [
+ content.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor),
+ self.contentView.trailingAnchor.constraint(equalTo: content.trailingAnchor),
+ ]
+ contentSafeAreaConstraints = [
+ content.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor),
+ safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: content.trailingAnchor),
+ ]
+ updateSafeAreaConstraints()
}
}
-
+
+ // MARK: - Safe area
+
+ private var contentEdgeConstraints: [NSLayoutConstraint] = []
+ private var contentSafeAreaConstraints: [NSLayoutConstraint] = []
+
+ override func updateSafeAreaConstraints() {
+ super.updateSafeAreaConstraints()
+
+ if keepsContentInSafeArea {
+ NSLayoutConstraint.deactivate(contentEdgeConstraints)
+ NSLayoutConstraint.activate(contentSafeAreaConstraints)
+ } else {
+ NSLayoutConstraint.deactivate(contentSafeAreaConstraints)
+ NSLayoutConstraint.activate(contentEdgeConstraints)
+ }
+ }
+
}
diff --git a/TGCardViewController.podspec b/TGCardViewController.podspec
index c34db8f..7231917 100644
--- a/TGCardViewController.podspec
+++ b/TGCardViewController.podspec
@@ -17,7 +17,7 @@ Pod::Spec.new do |s|
# ――― Platform Specifics ――――――――――――――――――――――――――――――――――――――――――――――――――――――― #
#
- s.platform = :ios, "13.0"
+ s.platform = :ios, "16.0"
s.swift_version = '5.5'
# ――― Source Location ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――― #