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 ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――― #