From 7c89b23aa8b979f1b494291d979e34d0e0ba1709 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 09:41:51 +1100 Subject: [PATCH 01/35] build: require iOS 16 Xcode 27 no longer builds for iOS 13, and the system sheet work needs iOS 16 APIs (custom detents). TripKitUI already requires iOS 16, so consumers are unaffected. Bumps tools-version to 5.7 for `.v16`. Co-Authored-By: Claude Opus 5.5 --- Example/Example.xcodeproj/project.pbxproj | 4 ++-- Package.swift | 4 ++-- TGCardViewController.podspec | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) 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/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/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 ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――― # From be37cbc1148e70b12cf37f3c2031e7543def7a42 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 10:07:26 +1100 Subject: [PATCH 02/35] feat: show cards in a system sheet on iOS 26+ Adds `presentationStyle` (`.automatic`, `.classic`, `.systemSheet`). With `.automatic`, on iOS 26+ in compact width and regular height, the card stack moves into a `UISheetPresentationController` so cards get the system's floating glass sheet, dragging and scroll-to-expand. Regular width, compact height, Catalyst, sidebar mode and controllers that are themselves presented in a sheet keep the classic presentation, and the controller switches back and forth as size classes change. Only the cards move into the sheet. The card wrapper stays behind as an invisible placeholder that mirrors the sheet's top edge on every layout pass, so map buttons, map insets, the header and map managers work as before. The three card positions map to custom detents, the extended one leaving room for a header. The sheet never dims, so the map and header stay interactive. Since the sheet is this controller's `presentedViewController`, `present` and `dismiss` are routed to whatever is on top of the sheet, and appearance callbacks for full screen presentations are forwarded. New `presentedOverlayViewController` and `cardOverlayView` help consumers that relied on `presentedViewController` or on adding views to `view`. Co-Authored-By: Claude Opus 5.5 --- Example/ExampleCardViewController.swift | 6 + .../TGCardViewController+SystemSheet.swift | 611 ++++++++++++++++++ .../TGCardViewController.swift | 306 +++++++-- .../TGSheetHostViewController.swift | 159 +++++ 4 files changed, 1018 insertions(+), 64 deletions(-) create mode 100644 Sources/TGCardViewController/TGCardViewController+SystemSheet.swift create mode 100644 Sources/TGCardViewController/TGSheetHostViewController.swift 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/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift new file mode 100644 index 0000000..61196ad --- /dev/null +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -0,0 +1,611 @@ +// +// 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 { + + /// Uses ``systemSheet`` where it's a good fit, and ``classic`` otherwise. + /// + /// System sheets are used on iOS 26 and later, in ``Mode-swift.enum/floating`` + /// mode, in compact width and regular height (i.e., an iPhone in portrait or a + /// narrow iPad window), and only when the card controller isn't itself presented + /// in a sheet. + case automatic + + /// The card controller positions the cards itself, and they're dragged using + /// its own gestures. + case classic + + /// Cards are shown in a system sheet (`UISheetPresentationController`) whenever + /// the size classes allow it (compact width and regular height), and in + /// ``Mode-swift.enum/floating`` mode. Falls back to ``classic`` otherwise. + case systemSheet + } + + /// Whether the cards are currently shown in a system sheet. + /// + /// This can change during the lifetime of the controller, e.g., when rotating + /// the device. See ``presentationStyle``. + 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 { + + func wantsSystemSheet(for traits: UITraitCollection) -> Bool { +#if targetEnvironment(macCatalyst) || os(visionOS) + return false +#else + guard mode == .floating else { return false } + + switch presentationStyle { + case .classic: + return false + case .automatic: + guard #available(iOS 26.0, *) else { return false } + case .systemSheet: + break + } + + guard + traits.horizontalSizeClass == .compact, + traits.verticalSizeClass == .regular + 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 +#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(for: traitCollection) && 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. + let heightConstraint = content.heightAnchor.constraint(equalToConstant: estimatedSheetContentHeight(in: host)) + NSLayoutConstraint.activate([ + content.topAnchor.constraint(equalTo: host.view.topAnchor), + content.leadingAnchor.constraint(equalTo: host.view.leadingAnchor), + content.trailingAnchor.constraint(equalTo: host.view.trailingAnchor), + heightConstraint, + ]) + sheetContentHeightConstraint = heightConstraint + + // 2. Configure the sheet + sheetHost = host + isAwaitingSystemSheet = false + + if let sheet = host.sheetPresentationController { + sheet.delegate = host + sheet.prefersScrollingExpandsWhenScrolledToEdge = true + sheet.prefersEdgeAttachedInCompactHeight = true + sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = false + sheetTargetPosition = position + applySheetConfiguration(to: sheet, selecting: position) + } + + // 3. Hand over the chrome + updateCardChromeForPresentationStyle() + updateForNewPosition(position: position) + updateSheetContentScrollView() + + 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 + sheetTargetPosition = nil + sheetDetentValues = [:] + + let moveCardsBack = { [self] in + guard let content = cardWrapperContent else { return assertionFailure() } + + content.removeFromSuperview() + cardWrapperShadow.insertSubview(content, aboveSubview: cardWrapperEffectView) + NSLayoutConstraint.activate(savedCardContentConstraints) + savedCardContentConstraints = [] + sheetContentHeightConstraint = 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 needs to work where the cards are + edgePanner.view?.removeGestureRecognizer(edgePanner) + cardOverlayView.addGestureRecognizer(edgePanner) +#endif + + updateMapShadow(for: inSheet ? .collapsed : cardPosition) + updateGrabHandleVisibility() + updateCardScrolling(allow: inSheet || cardPosition == .extended, view: topCardView) + + for cardView in cards.compactMap(\.view) { + applyCorners(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 applyCorners(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 { + usesSystemSheet || 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) + } + +} + +// 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 + +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))) + } + +} + +extension TGCardViewController { + + /// 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) + } + + let collapsed = max( + Constants.minCardHeightWhenCollapsed, + topCardView?.headerHeight(for: .collapsed) ?? 0 + ) + + 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 + } + } + + /// 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) { + 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() + } + + /// The sheet's position, if the cards are shown in a sheet. + var sheetPosition: TGCardPosition? { + guard let sheet = sheetHost?.sheetPresentationController else { return nil } + return TGCardPosition(sheetDetentIdentifier: sheet.selectedDetentIdentifier) + ?? TGCardPosition(sheetDetentIdentifier: sheet.detents.first?.identifier) + } + + /// 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 + } + } + + // 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) + + // 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() + } + + /// 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 = view.bounds.height - peakingHeight + let extendedTop = view.bounds.height - 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..1151710 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,16 @@ 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 } public enum Mode { @@ -136,6 +139,32 @@ 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 26+ where they fit. 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 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 + /// 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 +279,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 +314,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 +338,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 +463,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 +473,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 +542,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 +580,8 @@ open class TGCardViewController: UIViewController { topCard?.didAppear(animated: animated) isVisible = true + + updateSystemSheetPresentation() } override open func viewWillDisappear(_ animated: Bool) { @@ -551,6 +589,31 @@ 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 + + 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) + } + + 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) { @@ -567,13 +630,27 @@ open class TGCardViewController: UIViewController { }, completion: nil) } + open override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) { + super.willTransition(to: newCollection, with: coordinator) + + // Leave the sheet before the size classes change, as the sheet would + // otherwise go full screen, and get it back once they have changed. + if usesSystemSheet, !wantsSystemSheet(for: newCollection) { + uninstallSystemSheet(animated: false) + } else if !usesSystemSheet, wantsSystemSheet(for: newCollection) { + coordinator.animate(alongsideTransition: nil) { [weak self] _ in + self?.updateSystemSheetPresentation() + } + } + } + override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) 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. @@ -594,11 +671,19 @@ open class TGCardViewController: UIViewController { // the card's contents to be scrollable. Hence, we reenable the scolling. updateCardScrolling(allow: true, view: topCardView) + if previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass + || previousTraitCollection?.verticalSizeClass != traitCollection.verticalSizeClass { + // In case `willTransition` wasn't called, e.g., when embedded + DispatchQueue.main.async { [weak self] in + self?.updateSystemSheetPresentation() + } + } + 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 +693,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(for: traitCollection) + initialCards.forEach { push($0, animated: false, allowToNotify: $0 == initialCards.last, completionHandler: nil) } } fixPositioning() + + if isVisible, !usesSystemSheet, presentedViewController == nil, topCardView != nil, wantsSystemSheet(for: traitCollection) { + // 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 +717,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 +733,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 +760,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 +776,7 @@ open class TGCardViewController: UIViewController { } } - fileprivate var extendedMinY: CGFloat { + var extendedMinY: CGFloat { var value = topOverlap if mode == .floating { @@ -704,13 +812,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 +854,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 +937,7 @@ extension TGCardViewController { return cards.last?.card } - private var topCardView: TGCardView? { + var topCardView: TGCardView? { return cards.last?.view } @@ -830,7 +947,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 +1002,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 +1043,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 +1055,16 @@ extension TGCardViewController { // 4. Place the new view coming, preparing to animate in from the bottom cardView.frame = cardWrapperContent.bounds - if animated { + if animated, let sheetHost { + // Come in from the bottom of the sheet + cardView.frame.origin.y = sheetHost.view.bounds.height + } 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 - } + applyCorners(to: cardView) cardWrapperContent.addSubview(cardView) // Give AutoLayout a nudge to layout the card view, now that we have @@ -983,7 +1101,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 @@ -1033,7 +1157,7 @@ extension TGCardViewController { // 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 #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 +1169,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) @@ -1081,6 +1205,8 @@ extension TGCardViewController { self.cardWrapperShadow.frame = oldShadowFrame } + applySheetPosition(animateTo.position, animated: animated) + UIView.animate( withDuration: animated ? Constants.pushAnimationDuration : 0, delay: 0, @@ -1111,6 +1237,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 +1279,8 @@ extension TGCardViewController { } isPopping = true + isTransitioningCards = true + defer { isTransitioningCards = false } let newTop = cardWithView(atIndex: cards.count - 2) let topView = topCardView @@ -1199,7 +1329,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 } @@ -1221,7 +1357,7 @@ extension TGCardViewController { // 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 #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 +1369,7 @@ extension TGCardViewController { #endif visualEffectView.contentView.addSubview(snapshot) visualEffectView.frame = newFrame - container.insertSubview(visualEffectView, aboveSubview: cardWrapperShadow) + container.insertSubview(visualEffectView, aboveSubview: cardTransitionAnchor) cardTransitionShadow = visualEffectView } else { @@ -1255,8 +1391,8 @@ extension TGCardViewController { } 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,6 +1400,8 @@ extension TGCardViewController { topView?.alpha = 0 } + applySheetPosition(animateTo, animated: animated) + UIView.animate( withDuration: animated ? Constants.pushAnimationDuration * 1.25 : 0, delay: 0, @@ -1294,6 +1432,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 +1497,7 @@ extension TGCardViewController { extension TGCardViewController { - fileprivate enum Direction { + enum Direction { case up case down @@ -1371,7 +1511,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 +1576,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 +1697,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 +1784,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 +1793,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 +1883,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 +1920,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 @@ -1772,11 +1934,12 @@ extension TGCardViewController { 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 +2011,13 @@ 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 + } 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 +2052,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 +2185,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 @@ -2081,7 +2248,7 @@ extension TGCardViewController { extension TGCardViewController { - fileprivate var isShowingHeader: Bool { + var isShowingHeader: Bool { return headerViewTopConstraint.constant > -1 } @@ -2157,6 +2324,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 +2349,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() @@ -2351,6 +2526,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 +2692,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..9c1ed5b --- /dev/null +++ b/Sources/TGCardViewController/TGSheetHostViewController.swift @@ -0,0 +1,159 @@ +// +// 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 using ``TGCardViewController/PresentationStyle-swift.enum/systemSheet``. +/// +/// 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 + 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 + } + + // 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() + } + } + + // 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 { + + func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) { + cardController?.sheetDidChangeSelectedDetent() + } + + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + false + } + +} From 9496578f6ab00745c8d02214c06ca62daa7447d6 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 10:14:11 +1100 Subject: [PATCH 03/35] docs: describe system sheet presentation Co-Authored-By: Claude Opus 5.5 --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index da50983..7df61bb 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,32 @@ Provides a card-based view controller for mapping applications where the card's } ``` +### System sheets on iOS 26+ + +By default (`presentationStyle = .automatic`), on iOS 26 and later the cards are shown in a system sheet (`UISheetPresentationController`) whenever that's a good fit: + +| | Presentation | +|---|---| +| iPhone in portrait, narrow iPad window (compact width, regular height) | System sheet | +| iPhone in landscape, iPad, Mac Catalyst, `mode = .sidebar` | Classic | +| The card controller is itself presented, but not full screen | Classic | +| iOS 25 and earlier | Classic | + +The controller switches between the two as the size classes change, e.g., when rotating. Set `presentationStyle` to `.classic` to opt out, or to `.systemSheet` to also use sheets on iOS 16 to 25. 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. + ## Specs ### 1. Basic functionality of cards From 0727ed402fbbc3de6f33419fa51432604805e4ad Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 10:21:41 +1100 Subject: [PATCH 04/35] fix: keep present and dismiss callable from non-isolated code UIKit's own present and dismiss are lenient for callers that aren't on the main actor yet, while the new overrides weren't, which broke such callers in TripGo. Co-Authored-By: Claude Opus 5.5 --- Sources/TGCardViewController/TGCardViewController.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index 1151710..a15ce78 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -599,6 +599,10 @@ open class TGCardViewController: UIViewController { // 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) @@ -606,6 +610,7 @@ open class TGCardViewController: UIViewController { 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 From 38bc7289e82b550e92541b2a138ae7636b7ef4c2 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 11:01:52 +1100 Subject: [PATCH 05/35] perf: take covered cards out of the view hierarchy Cards below the top card used to stay in the view hierarchy with an alpha of 0, so every card in the stack kept taking part in layout, rendering and accessibility. Once a push has finished, the covered card's view is now removed from its superview and only added back when the card above it gets popped. The views stay alive, so cards keep their state, such as scroll positions and bindings set up in `didBuild`. SwiftUI views in covered cards now get `onDisappear` and their `.task`s get cancelled, and both run again when the card is revealed. Also no longer hides a card that was popped back to before the push of the card above it had finished. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController.swift | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index a15ce78..5fcb8f6 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -1231,7 +1231,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) @@ -1325,7 +1328,9 @@ 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) + } // We only animate to the previous position if the card obscures the map updateCardStructure(card: newTop?.view, position: newTop?.lastPosition) @@ -1935,6 +1940,42 @@ 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 { From cc1329fb601eb460e3076cf9d4951be901460ee8 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 11:11:03 +1100 Subject: [PATCH 06/35] fix: clip cards to the sheet's rounded corners The system sheet rounds its background but doesn't clip its content, so opaque card content, like TripKitUI's grouped table cells, stuck out past the sheet's bottom corners. Clips the sheet's view with a concentric corner radius. Co-Authored-By: Claude Opus 5.5 --- Sources/TGCardViewController/TGSheetHostViewController.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/TGCardViewController/TGSheetHostViewController.swift b/Sources/TGCardViewController/TGSheetHostViewController.swift index 9c1ed5b..2fa840b 100644 --- a/Sources/TGCardViewController/TGSheetHostViewController.swift +++ b/Sources/TGCardViewController/TGSheetHostViewController.swift @@ -44,6 +44,11 @@ final class TGSheetHostViewController: UIViewController { let view = UIView() view.backgroundColor = .clear view.clipsToBounds = true + if #available(iOS 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 } From 8827975a1d60ca92b125e80ad8cce0900afe36a6 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 11:54:23 +1100 Subject: [PATCH 07/35] feat: cross-fade cards when pushing and popping in a system sheet The classic slide-up-and-bounce suggests a new card landing on top of the old one, which fits the classic presentation, but looks odd inside a sheet that's already there. In a system sheet, cards now cross-fade in place instead, while the sheet animates to the new card's detent. Also keeps swiping from the screen's edge to pop working on the map in a system sheet, and adds it to the sheet itself, too. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController+SystemSheet.swift | 11 +++++-- .../TGCardViewController.swift | 33 ++++++++++++++----- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index 61196ad..2822a8c 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -264,9 +264,14 @@ extension TGCardViewController { } #if !os(visionOS) - // Popping by swiping from the edge needs to work where the cards are - edgePanner.view?.removeGestureRecognizer(edgePanner) - cardOverlayView.addGestureRecognizer(edgePanner) + // 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) diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index 5fcb8f6..df7522a 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -107,6 +107,9 @@ open class TGCardViewController: UIViewController { /// The space between the header and a system sheet, when the sheet is extended. static let sheetSpacingBelowHeader: CGFloat = 8 + + /// Pushing and popping cross-fade between cards in a system sheet + static let sheetCrossFadeDuration = 0.25 } public enum Mode { @@ -1060,9 +1063,10 @@ extension TGCardViewController { // 4. Place the new view coming, preparing to animate in from the bottom cardView.frame = cardWrapperContent.bounds - if animated, let sheetHost { - // Come in from the bottom of the sheet - cardView.frame.origin.y = sheetHost.view.bounds.height + 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 @@ -1161,7 +1165,7 @@ 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 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) @@ -1191,6 +1195,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 @@ -1213,9 +1222,9 @@ extension TGCardViewController { 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: { @@ -1330,6 +1339,9 @@ extension TGCardViewController { // 4. Determine and set new position of the card wrapper (relative to header!) if let newView = newTop?.view { reattachCardView(newView, below: topView) + if animated, usesSystemSheet { + newView.alpha = 0 // and cross-fade below + } } // We only animate to the previous position if the card obscures the map @@ -1366,7 +1378,7 @@ 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 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 @@ -1395,7 +1407,10 @@ 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 { @@ -1413,7 +1428,7 @@ extension TGCardViewController { 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, From 2d89bb8db6d5d6eae97e1f31e334c0761516b0fe Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 13:16:27 +1100 Subject: [PATCH 08/35] fix(example): adopt the scene lifecycle iOS 27.1 no longer launches apps that don't support scenes, so the Example app crashed on launch on the iPhone Duo simulator. Co-Authored-By: Claude Opus 5.5 --- Example/AppDelegate.swift | 8 ++++++++ Example/Supporting Files/Info.plist | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) 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/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 From 75f8c9a84b6afb54591ae8c3d3fad7d9b76b09f9 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 13:48:10 +1100 Subject: [PATCH 09/35] feat: put the close button where the system does next to a vertical bar On devices with a vertical bar, like the iPhone Duo's outer display, the system puts a sheet's close button into that bar, below the status bar once the sheet is at full height. Cards have their close buttons in their titles, which then ended up under the status bar, or in a different spot depending on whether the title view respected the safe area. While the cards are in a system sheet next to a vertical bar, the close buttons in the top card's titles are now hidden (keeping their layout), and a stand-in close button is pinned to the sheet's bar layout region (`layoutGuide(for: .bar(onEdge:extent:))`, iOS 27.1), which forwards taps to the card's own button. It follows the bar as the sheet changes height, when the device folds, and when switching pages of a page card. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController+SystemSheet.swift | 104 ++++++++++++++++++ .../TGCardViewController.swift | 13 +++ .../TGSheetHostViewController.swift | 15 +++ 3 files changed, 132 insertions(+) diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index 2822a8c..ca223c7 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -201,6 +201,7 @@ extension TGCardViewController { sheetHost = nil sheetTargetPosition = nil sheetDetentValues = [:] + updateSheetBarCloseButton() let moveCardsBack = { [self] in guard let content = cardWrapperContent else { return assertionFailure() } @@ -339,10 +340,113 @@ extension TGCardViewController { /// Tells the sheet which scroll view to track for scrolling vs. resizing. func updateSheetContentScrollView() { sheetHost?.setContentScrollView(topCardView?.contentScrollView) + updateSheetBarCloseButton() } } +// MARK: - Close button in the vertical bar + +extension TGCardViewController { + + /// The width of the bar region that the close button is centred in, which + /// matches the system's close buttons. + private static let barCloseButtonExtent: CGFloat = 44 + + /// On devices with a vertical bar, e.g., the iPhone Duo's outer display, the + /// system puts a sheet's close button into that bar, below the status bar + /// when the sheet is at full height. Cards have their close buttons in their + /// titles, which then end up under the status bar. 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 where the system puts its close + /// buttons, which forwards taps to the card's own. + func updateSheetBarCloseButton() { + guard #available(iOS 27.1, *) else { return } + + let closeButtons: [UIButton] + let currentCloseButton: UIButton? + 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 + } else { + currentCloseButton = topCardView?.dismissButton + closeButtons = [currentCloseButton].compactMap { $0 } + } + + let barEdge = sheetHost?.traitCollection.verticalBarEdge ?? .unspecified + guard + let host = sheetHost, + barEdge != .unspecified, + let currentCloseButton + else { + suppressedCloseButtons.forEach { Self.setCloseButton($0, suppressed: false) } + suppressedCloseButtons = [] + sheetBarCloseButton?.removeFromSuperview() + sheetBarCloseButton = nil + sheetBarCloseButtonEdge = 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 + + // The stand-in, centred at the top of the bar region, which follows the + // bar: in it at full height, at the top of the sheet otherwise + let edge: NSDirectionalRectEdge = barEdge == .leading ? .leading : .trailing + let barButton: UIButton + if let existing = sheetBarCloseButton, sheetBarCloseButtonEdge == edge, existing.superview === host.view { + barButton = existing + } else { + sheetBarCloseButton?.removeFromSuperview() + barButton = UIButton(type: .system) + barButton.translatesAutoresizingMaskIntoConstraints = false + barButton.addAction(UIAction { [weak self] _ in + self?.forwardBarCloseButtonTap() + }, for: .touchUpInside) + host.view.addSubview(barButton) + + let guide = host.view.layoutGuide(for: .bar(onEdge: edge, extent: Self.barCloseButtonExtent)) + NSLayoutConstraint.activate([ + barButton.topAnchor.constraint(equalTo: guide.topAnchor), + barButton.centerXAnchor.constraint(equalTo: guide.centerXAnchor), + ]) + sheetBarCloseButton = barButton + sheetBarCloseButtonEdge = edge + } + + TGCard.configureCloseButton(barButton, style: topCard?.style ?? .default) + barButton.accessibilityLabel = currentCloseButton.accessibilityLabel + ?? NSLocalizedString("Close card", bundle: TGCardViewController.bundle, comment: "") + barButton.isSpringLoaded = currentCloseButton.isSpringLoaded + barButton.isHidden = currentCloseButton.isHidden + host.view.bringSubviewToFront(barButton) + } + + private func forwardBarCloseButtonTap() { + 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 + } + +} + // MARK: - Routing presentations extension TGCardViewController { diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index df7522a..52eadfa 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -168,6 +168,15 @@ open class TGCardViewController: UIViewController { /// Set while pushing or popping, which update the sheet in one go var isTransitioningCards = false + /// Stand-in for the top card's close button, placed where the system puts + /// close buttons on devices with a vertical bar, e.g., iPhone Duo + var sheetBarCloseButton: UIButton? + var sheetBarCloseButtonEdge: NSDirectionalRectEdge? + + /// The close buttons in the titles of the top card, hidden while + /// `sheetBarCloseButton` stands 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. @@ -1127,6 +1136,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 + updateSheetBarCloseButton() + // 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. @@ -1343,6 +1355,7 @@ extension TGCardViewController { newView.alpha = 0 // and cross-fade below } } + updateSheetBarCloseButton() // We only animate to the previous position if the card obscures the map updateCardStructure(card: newTop?.view, position: newTop?.lastPosition) diff --git a/Sources/TGCardViewController/TGSheetHostViewController.swift b/Sources/TGCardViewController/TGSheetHostViewController.swift index 2fa840b..ddb8212 100644 --- a/Sources/TGCardViewController/TGSheetHostViewController.swift +++ b/Sources/TGCardViewController/TGSheetHostViewController.swift @@ -59,6 +59,18 @@ final class TGSheetHostViewController: UIViewController { cardController ?? super.next } + override func viewDidLoad() { + super.viewDidLoad() + + 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?.updateSheetBarCloseButton() + } + } + } + // MARK: - Appearance // Something presented full screen on top of the sheet would have made the card @@ -97,6 +109,9 @@ final class TGSheetHostViewController: UIViewController { isCoveredByPresentation = false cardController?.endAppearanceTransition() } + + // Only now is it in its final place, which determines the vertical bar + cardController?.updateSheetBarCloseButton() } // MARK: - Layout From 9c9eafbdec7e7817220b3c7b13db438cca3f53e9 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 14:41:26 +1100 Subject: [PATCH 10/35] feat: show card actions and paging in the vertical bar On devices with a vertical bar, such as the iPhone Duo's outer display, the top card's items now go where the system puts a navigation bar's: the close button and, for page cards, previous/next buttons at the top of the bar, and the card's new `barActions` grouped at the bottom, all icon-only. They stay in the bar at every height of the sheet: one copy sits over the map and another on the sheet, each showing where it's in front. Cards can observe `showsBarActions` to leave those actions out of their own content while they're in the bar. Co-Authored-By: Claude Opus 5.5 --- Example/cards/ExampleCityCard.swift | 27 ++ README.md | 2 + .../TGCardViewController+SystemSheet.swift | 395 ++++++++++++++++-- .../TGCardViewController.swift | 22 +- .../TGSheetHostViewController.swift | 4 +- .../TGCardViewController/cards/TGCard.swift | 25 ++ 6 files changed, 422 insertions(+), 53 deletions(-) diff --git a/Example/cards/ExampleCityCard.swift b/Example/cards/ExampleCityCard.swift index 6d87d8c..3ffb378 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 `showsBarActions` is `false`. + private func updateBarActions() { + barActions = [ + 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/README.md b/README.md index 7df61bb..7eeb409 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,8 @@ Because the sheet is the card controller's `presentedViewController`: - 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 `barActions` 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 `showsBarActions` 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 index ca223c7..4522a03 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -201,7 +201,7 @@ extension TGCardViewController { sheetHost = nil sheetTargetPosition = nil sheetDetentValues = [:] - updateSheetBarCloseButton() + updateSheetBarItems() let moveCardsBack = { [self] in guard let content = cardWrapperContent else { return assertionFailure() } @@ -340,51 +340,66 @@ extension TGCardViewController { /// Tells the sheet which scroll view to track for scrolling vs. resizing. func updateSheetContentScrollView() { sheetHost?.setContentScrollView(topCardView?.contentScrollView) - updateSheetBarCloseButton() + updateSheetBarItems() } } -// MARK: - Close button in the vertical bar +// MARK: - Bar items next to a vertical bar extension TGCardViewController { - /// The width of the bar region that the close button is centred in, which - /// matches the system's close buttons. - private static let barCloseButtonExtent: CGFloat = 44 - /// On devices with a vertical bar, e.g., the iPhone Duo's outer display, the - /// system puts a sheet's close button into that bar, below the status bar - /// when the sheet is at full height. Cards have their close buttons in their - /// titles, which then end up under the status bar. 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 where the system puts its close - /// buttons, which forwards taps to the card's own. - func updateSheetBarCloseButton() { + /// 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 + /// `barActions` 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 `syncSheetBarItems(sheetTop:)`. + func updateSheetBarItems() { guard #available(iOS 27.1, *) else { return } + let barEdge = sheetHost?.traitCollection.verticalBarEdge ?? .unspecified + let showsBar = sheetHost != nil && barEdge != .unspecified + updateShowsBarActions(showsBar) + let closeButtons: [UIButton] let currentCloseButton: UIButton? + let actions: [UIAction] + let paging: TGBarItemsView.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.barActions + pageCard.currentCard.barActions + 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?.barActions ?? [] + paging = nil } - let barEdge = sheetHost?.traitCollection.verticalBarEdge ?? .unspecified guard + showsBar, let host = sheetHost, - barEdge != .unspecified, - let currentCloseButton + currentCloseButton != nil || paging != nil || !actions.isEmpty else { suppressedCloseButtons.forEach { Self.setCloseButton($0, suppressed: false) } suppressedCloseButtons = [] - sheetBarCloseButton?.removeFromSuperview() - sheetBarCloseButton = nil - sheetBarCloseButtonEdge = nil + mapBarItems?.removeFromSuperview() + mapBarItems = nil + sheetBarItems?.removeFromSuperview() + sheetBarItems = nil + barItemsEdge = nil return } @@ -395,36 +410,107 @@ extension TGCardViewController { closeButtons.forEach { Self.setCloseButton($0, suppressed: true) } suppressedCloseButtons = closeButtons - // The stand-in, centred at the top of the bar region, which follows the - // bar: in it at full height, at the top of the sheet otherwise let edge: NSDirectionalRectEdge = barEdge == .leading ? .leading : .trailing - let barButton: UIButton - if let existing = sheetBarCloseButton, sheetBarCloseButtonEdge == edge, existing.superview === host.view { - barButton = existing + let mapItems: TGBarItemsView + let sheetItems: TGBarItemsView + if let existingMap = mapBarItems, let existingSheet = sheetBarItems, barItemsEdge == edge, existingMap.superview === view, existingSheet.superview === host.view { + mapItems = existingMap + sheetItems = existingSheet } else { - sheetBarCloseButton?.removeFromSuperview() - barButton = UIButton(type: .system) - barButton.translatesAutoresizingMaskIntoConstraints = false - barButton.addAction(UIAction { [weak self] _ in - self?.forwardBarCloseButtonTap() - }, for: .touchUpInside) - host.view.addSubview(barButton) + mapBarItems?.removeFromSuperview() + sheetBarItems?.removeFromSuperview() - let guide = host.view.layoutGuide(for: .bar(onEdge: edge, extent: Self.barCloseButtonExtent)) + // 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: TGBarItemsView.itemSize)) NSLayoutConstraint.activate([ - barButton.topAnchor.constraint(equalTo: guide.topAnchor), - barButton.centerXAnchor.constraint(equalTo: guide.centerXAnchor), + mapItems.topAnchor.constraint(equalTo: guide.topAnchor), + mapItems.bottomAnchor.constraint(equalTo: guide.bottomAnchor), + mapItems.centerXAnchor.constraint(equalTo: guide.centerXAnchor), + mapItems.widthAnchor.constraint(equalToConstant: TGBarItemsView.itemSize), ]) - sheetBarCloseButton = barButton - sheetBarCloseButtonEdge = edge + + // Positioned to match `mapItems` whenever the sheet lays out + sheetItems = makeBarItemsView() + host.view.addSubview(sheetItems) + + mapBarItems = mapItems + sheetBarItems = sheetItems + barItemsEdge = edge } - TGCard.configureCloseButton(barButton, style: topCard?.style ?? .default) - barButton.accessibilityLabel = currentCloseButton.accessibilityLabel - ?? NSLocalizedString("Close card", bundle: TGCardViewController.bundle, comment: "") - barButton.isSpringLoaded = currentCloseButton.isSpringLoaded - barButton.isHidden = currentCloseButton.isHidden - host.view.bringSubviewToFront(barButton) + 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() + syncSheetBarItems(sheetTop: host.view.convert(host.view.bounds, to: view).minY) + } + + /// 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 syncSheetBarItems(sheetTop: CGFloat) { + guard + let mapItems = mapBarItems, + let sheetItems = sheetBarItems, + let host = sheetHost + else { return } + + let frame = host.view.convert(mapItems.frame, from: view) + if sheetItems.frame != frame { + sheetItems.frame = frame + } + sheetItems.layoutIfNeeded() + + // 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 + } + } + + private func makeBarItemsView() -> TGBarItemsView { + TGBarItemsView { [weak self] in + self?.forwardBarCloseButtonTap() + } 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 bar actions are shown, so that they + /// can leave them out of their content. + private func updateShowsBarActions(_ shows: Bool) { + for card in cards.map(\.card) { + let pages = (card as? TGPageCard)?.cards ?? [] + for card in [card] + pages where card.showsBarActions != shows { + card.showsBarActions = shows + } + } } private func forwardBarCloseButtonTap() { @@ -447,6 +533,227 @@ extension TGCardViewController { } +/// 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 TGBarItemsView: 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 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 + private 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 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, *) { +#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 { @@ -683,6 +990,8 @@ extension TGCardViewController { // animation. Laying out now has the map buttons and insets follow along, // rather than jumping ahead. view.layoutIfNeeded() + + syncSheetBarItems(sheetTop: sheetTop) } /// Like the classic card does while dragging, fade out the map's buttons as diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index 52eadfa..cccca7a 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -168,13 +168,17 @@ open class TGCardViewController: UIViewController { /// Set while pushing or popping, which update the sheet in one go var isTransitioningCards = false - /// Stand-in for the top card's close button, placed where the system puts - /// close buttons on devices with a vertical bar, e.g., iPhone Duo - var sheetBarCloseButton: UIButton? - var sheetBarCloseButtonEdge: NSDirectionalRectEdge? + /// 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 mapBarItems: TGBarItemsView? - /// The close buttons in the titles of the top card, hidden while - /// `sheetBarCloseButton` stands in for them + /// ... and this one is on the sheet, for when that covers the vertical bar. + var sheetBarItems: TGBarItemsView? + + var barItemsEdge: NSDirectionalRectEdge? + + /// 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 @@ -1137,7 +1141,7 @@ extension TGCardViewController { updateCardFloatingViewContent(card: top) // Before the new card fades in, so that its own close button doesn't - updateSheetBarCloseButton() + updateSheetBarItems() // 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 @@ -1355,7 +1359,7 @@ extension TGCardViewController { newView.alpha = 0 // and cross-fade below } } - updateSheetBarCloseButton() + updateSheetBarItems() // We only animate to the previous position if the card obscures the map updateCardStructure(card: newTop?.view, position: newTop?.lastPosition) @@ -2087,6 +2091,8 @@ extension TGCardViewController { // hide the card and disable all card-based interaction if let sheetHost = self.sheetHost { sheetHost.presentationController?.containerView?.alpha = show ? 1 : 0 + self.mapBarItems?.alpha = show ? 1 : 0 + self.mapBarItems?.isUserInteractionEnabled = show } else { self.panner.isEnabled = show self.cardWrapperShadow?.isUserInteractionEnabled = show diff --git a/Sources/TGCardViewController/TGSheetHostViewController.swift b/Sources/TGCardViewController/TGSheetHostViewController.swift index ddb8212..bdb1e70 100644 --- a/Sources/TGCardViewController/TGSheetHostViewController.swift +++ b/Sources/TGCardViewController/TGSheetHostViewController.swift @@ -66,7 +66,7 @@ final class TGSheetHostViewController: UIViewController { // The close button follows the vertical bar, e.g., when folding an // iPhone Duo registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) { (host: TGSheetHostViewController, _: UITraitCollection) in - host.cardController?.updateSheetBarCloseButton() + host.cardController?.updateSheetBarItems() } } } @@ -111,7 +111,7 @@ final class TGSheetHostViewController: UIViewController { } // Only now is it in its final place, which determines the vertical bar - cardController?.updateSheetBarCloseButton() + cardController?.updateSheetBarItems() } // MARK: - Layout diff --git a/Sources/TGCardViewController/cards/TGCard.swift b/Sources/TGCardViewController/cards/TGCard.swift index 776bec6..a29378b 100644 --- a/Sources/TGCardViewController/cards/TGCard.swift +++ b/Sources/TGCardViewController/cards/TGCard.swift @@ -252,6 +252,31 @@ 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 outside of the card. + /// + /// 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 bar actions show as + /// icon-only buttons in that bar, below the close button, like a navigation + /// bar's items would. Otherwise they're not shown, so the card should then + /// show them itself; `showsBarActions` 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 barActions: [UIAction] = [] { + didSet { controller?.updateSheetBarItems() } + } + + /// Whether the card controller shows the card's `barActions` outside of the + /// card, while it's 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 showsBarActions: Bool = false /// Builds the card's optional header which will be pinned to the top /// From 9b1b9fa7583230a79e358c60aa31cf7af117bf44 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 14:41:26 +1100 Subject: [PATCH 11/35] fix: keep cards in a sheet clear of a vertical bar Cards in the sheet now stay within its safe area horizontally. On the iPhone Duo, that's what keeps content out from under the vertical bar and its items, which the sheet reserves at every height. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController+SystemSheet.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index 4522a03..8af3242 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -160,11 +160,13 @@ extension TGCardViewController { // 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, which keeps them clear of + // a vertical bar, e.g., on an iPhone Duo. let heightConstraint = content.heightAnchor.constraint(equalToConstant: estimatedSheetContentHeight(in: host)) NSLayoutConstraint.activate([ content.topAnchor.constraint(equalTo: host.view.topAnchor), - content.leadingAnchor.constraint(equalTo: host.view.leadingAnchor), - content.trailingAnchor.constraint(equalTo: host.view.trailingAnchor), + content.leadingAnchor.constraint(equalTo: host.view.safeAreaLayoutGuide.leadingAnchor), + content.trailingAnchor.constraint(equalTo: host.view.safeAreaLayoutGuide.trailingAnchor), heightConstraint, ]) sheetContentHeightConstraint = heightConstraint From ce5b6fadf5365671a6ec26a6fb3de367f9391808 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 15:19:12 +1100 Subject: [PATCH 12/35] fix: balance a collapsed card's title in a floating sheet When collapsed, the sheet now leaves as much space below the title's content as there is above it, where the grabber sits. That's measured from what the title draws, as title views have their own margins, e.g., the home card's search bar sits close to the grab handle. It applies while the sheet is small enough to float. Taller sheets attach to the bottom edge, where UIKit adds the bottom safe area below the title, so those keep ending where the header does. Like classic cards, the content now also fades out towards the collapsed height, so that nothing below the title peeks into the sheet. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController+SystemSheet.swift | 34 ++++++++++++-- .../TGCardViewController.swift | 5 ++ .../cards/TGCardView.swift | 47 +++++++++++++++++++ 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index 8af3242..266c2c6 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -313,7 +313,7 @@ extension TGCardViewController { /// 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 { - usesSystemSheet || position != .collapsed ? 1 : 0 + position != .collapsed ? 1 : 0 } func forcesSeparatorHidden(for position: TGCardPosition) -> Bool { @@ -853,10 +853,14 @@ extension TGCardViewController { extended = min(extended, available) } - let collapsed = max( - Constants.minCardHeightWhenCollapsed, - topCardView?.headerHeight(for: .collapsed) ?? 0 - ) + // 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 { @@ -987,6 +991,7 @@ extension TGCardViewController { } 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, @@ -996,6 +1001,25 @@ extension TGCardViewController { syncSheetBarItems(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 collapsedHeight = sheetHeight(for: .collapsed), + let peakingHeight = sheetHeight(for: .peaking), + peakingHeight > collapsedHeight + else { return } + + let height = view.bounds.height - sheetTop + let alpha = min(1, max(0, (height - collapsedHeight) / (peakingHeight - collapsedHeight))) + if let current = cardView.contentScrollView?.alpha, abs(current - alpha) < 0.01 { + return + } + cardView.adjustContentAlpha(to: alpha) + } + /// 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. diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index cccca7a..71bcf80 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -108,6 +108,11 @@ open class TGCardViewController: UIViewController { /// The space between the header and a system sheet, when the sheet is extended. static let sheetSpacingBelowHeader: 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 } diff --git a/Sources/TGCardViewController/cards/TGCardView.swift b/Sources/TGCardViewController/cards/TGCardView.swift index 21e97ed..83a8ba3 100644 --- a/Sources/TGCardViewController/cards/TGCardView.swift +++ b/Sources/TGCardViewController/cards/TGCardView.swift @@ -228,6 +228,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) { From d6a1986e83e990871b66f6109ad6b75d408a0e6a Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 15:26:07 +1100 Subject: [PATCH 13/35] feat: fade in a background colour behind raised cards `TGCardStyle.expandedBackgroundColor` fades in behind the whole card as it moves up from collapsed, along with its content, and fully shows when it's peaking or extended. With a clear `backgroundColor` on iOS 26, that keeps the map from shining through the content of a raised card, while a collapsed card still shows its material. Applies to both system sheets and classic cards; not on Mac Catalyst. Co-Authored-By: Claude Opus 5.5 --- .../cards/TGCardStyle.swift | 11 +++++++ .../cards/TGCardView.swift | 33 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/Sources/TGCardViewController/cards/TGCardStyle.swift b/Sources/TGCardViewController/cards/TGCardStyle.swift index 6ff5324..48b65da 100644 --- a/Sources/TGCardViewController/cards/TGCardStyle.swift +++ b/Sources/TGCardViewController/cards/TGCardStyle.swift @@ -43,6 +43,17 @@ public struct TGCardStyle { } }() + /// Colour to fade in behind the card as it moves up from collapsed, and + /// which fully shows when the card is 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 + /// from the map, while a collapsed card still shows its material. + /// + /// - Note: Not used on Mac Catalyst, where cards are always clear. + 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 83a8ba3..74cf821 100644 --- a/Sources/TGCardViewController/cards/TGCardView.swift +++ b/Sources/TGCardViewController/cards/TGCardView.swift @@ -284,9 +284,41 @@ public class TGCardView: TGCornerView, TGPreferrableView { backgroundColor = .clear #else backgroundColor = style.backgroundColor + applyExpandedBackground(style.expandedBackgroundColor) #endif } + /// Shows `TGCardStyle.expandedBackgroundColor` behind everything, faded in + /// along with the content, see `adjustContentAlpha(to:)` + private var expandedBackgroundView: UIView? + + private func applyExpandedBackground(_ color: UIColor?) { + guard let color else { + expandedBackgroundView?.removeFromSuperview() + expandedBackgroundView = nil + return + } + + let background: UIView + if let existing = expandedBackgroundView { + background = existing + } else { + background = UIView() + background.isUserInteractionEnabled = false + background.alpha = contentScrollView?.alpha ?? 0 + background.translatesAutoresizingMaskIntoConstraints = false + insertSubview(background, at: 0) + NSLayoutConstraint.activate([ + background.topAnchor.constraint(equalTo: topAnchor), + background.bottomAnchor.constraint(equalTo: bottomAnchor), + background.leadingAnchor.constraint(equalTo: leadingAnchor), + background.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + expandedBackgroundView = background + } + background.backgroundColor = color + } + // MARK: - Content view configuration private var forceHideSeparator = false @@ -329,6 +361,7 @@ public class TGCardView: TGCornerView, TGPreferrableView { owningCard?.willAdjustContentAlpha(value) contentScrollView?.alpha = value + expandedBackgroundView?.alpha = value } } From 33bda3d9c684ee3b4760cf82e3e0e04f73b0f686 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 17:58:27 +1100 Subject: [PATCH 14/35] fix: show a raised card's background across the whole sheet In a system sheet, the cards stay within the sheet's safe area, which leaves out the column next to a vertical bar, e.g., on the iPhone Duo. The expanded background now goes on the sheet itself, so that it also covers that column, rather than leaving a strip of glass next to it. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController+SystemSheet.swift | 43 ++++++++++++++++++- .../TGCardViewController.swift | 6 ++- .../cards/TGCardView.swift | 7 +++ .../cards/TGPageCardView.swift | 4 ++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index 266c2c6..90611fc 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -201,6 +201,7 @@ extension TGCardViewController { let position = cardPosition sheetHost = nil + sheetBackgroundView = nil sheetTargetPosition = nil sheetDetentValues = [:] updateSheetBarItems() @@ -282,7 +283,7 @@ extension TGCardViewController { updateCardScrolling(allow: inSheet || cardPosition == .extended, view: topCardView) for cardView in cards.compactMap(\.view) { - applyCorners(to: cardView) + applyPresentationStyle(to: cardView) cardView.adjustContentAlpha(to: contentAlpha(for: cardPosition)) } @@ -295,7 +296,10 @@ extension TGCardViewController { view.setNeedsLayout() } - func applyCorners(to cardView: TGCardView) { + func applyPresentationStyle(to cardView: TGCardView) { + // The sheet shows the background itself, as the cards stay in its safe area + cardView.isExpandedBackgroundSuppressed = usesSystemSheet + guard #available(iOS 26.0, visionOS 26.0, *) else { return } if usesSystemSheet { @@ -1014,12 +1018,47 @@ extension TGCardViewController { let height = view.bounds.height - sheetTop let alpha = min(1, max(0, (height - collapsedHeight) / (peakingHeight - collapsedHeight))) + updateSheetBackground(alpha: alpha) + if let current = cardView.contentScrollView?.alpha, abs(current - alpha) < 0.01 { return } cardView.adjustContentAlpha(to: alpha) } + /// Shows the top card's `expandedBackgroundColor` across the whole sheet + private func updateSheetBackground(alpha: CGFloat) { + 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 + } + } + /// 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. diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index 71bcf80..cf05564 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -177,6 +177,10 @@ open class TGCardViewController: UIViewController { /// on devices that have one, e.g., iPhone Duo. This one is over the map... var mapBarItems: TGBarItemsView? + /// 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 sheetBarItems: TGBarItemsView? @@ -1091,7 +1095,7 @@ extension TGCardViewController { cardWrapperEffectView.frame.origin.y = offset } - applyCorners(to: cardView) + applyPresentationStyle(to: cardView) cardWrapperContent.addSubview(cardView) // Give AutoLayout a nudge to layout the card view, now that we have diff --git a/Sources/TGCardViewController/cards/TGCardView.swift b/Sources/TGCardViewController/cards/TGCardView.swift index 74cf821..bcd87e9 100644 --- a/Sources/TGCardViewController/cards/TGCardView.swift +++ b/Sources/TGCardViewController/cards/TGCardView.swift @@ -292,6 +292,12 @@ public class TGCardView: TGCornerView, TGPreferrableView { /// along with the content, see `adjustContentAlpha(to:)` private var expandedBackgroundView: UIView? + /// Set while the card is in a system sheet, which shows the expanded + /// background itself, across its full width + var isExpandedBackgroundSuppressed = false { + didSet { expandedBackgroundView?.isHidden = isExpandedBackgroundSuppressed } + } + private func applyExpandedBackground(_ color: UIColor?) { guard let color else { expandedBackgroundView?.removeFromSuperview() @@ -305,6 +311,7 @@ public class TGCardView: TGCornerView, TGPreferrableView { } else { background = UIView() background.isUserInteractionEnabled = false + background.isHidden = isExpandedBackgroundSuppressed background.alpha = contentScrollView?.alpha ?? 0 background.translatesAutoresizingMaskIntoConstraints = false insertSubview(background, at: 0) diff --git a/Sources/TGCardViewController/cards/TGPageCardView.swift b/Sources/TGCardViewController/cards/TGPageCardView.swift index 0367bce..5ad161f 100644 --- a/Sources/TGCardViewController/cards/TGPageCardView.swift +++ b/Sources/TGCardViewController/cards/TGPageCardView.swift @@ -210,6 +210,10 @@ class TGPageCardView: TGCardView { cardViews.forEach { $0.contentScrollView?.isScrollEnabled = allowScrolling } } + override var isExpandedBackgroundSuppressed: Bool { + didSet { cardViews.forEach { $0.isExpandedBackgroundSuppressed = isExpandedBackgroundSuppressed } } + } + override func adjustContentAlpha(to value: CGFloat) { // not calling super on purpose. cardViews.forEach { $0.adjustContentAlpha(to: value) } From faf80672a2e2478f627ea1dc916ccb783c909755 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 18:14:39 +1100 Subject: [PATCH 15/35] fix: keep building with Xcode 26, which lacks the vertical bar APIs The vertical bar APIs for the iPhone Duo come with the iOS 27.1 SDK. With older Xcodes, the cards keep their own close buttons and actions. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController/TGCardViewController+SystemSheet.swift | 2 ++ Sources/TGCardViewController/TGSheetHostViewController.swift | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index 90611fc..e438219 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -368,6 +368,7 @@ extension TGCardViewController { /// is over the map, and another one on the sheet, where the sheet covers the /// bar; see `syncSheetBarItems(sheetTop:)`. func updateSheetBarItems() { +#if compiler(>=6.4) // Xcode 27 proxy, which has the vertical bar APIs guard #available(iOS 27.1, *) else { return } let barEdge = sheetHost?.traitCollection.verticalBarEdge ?? .unspecified @@ -455,6 +456,7 @@ extension TGCardViewController { view.layoutIfNeeded() syncSheetBarItems(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 diff --git a/Sources/TGCardViewController/TGSheetHostViewController.swift b/Sources/TGCardViewController/TGSheetHostViewController.swift index bdb1e70..63101b5 100644 --- a/Sources/TGCardViewController/TGSheetHostViewController.swift +++ b/Sources/TGCardViewController/TGSheetHostViewController.swift @@ -62,6 +62,7 @@ final class TGSheetHostViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() +#if compiler(>=6.4) // Xcode 27 proxy, which has the vertical bar APIs if #available(iOS 27.1, *) { // The close button follows the vertical bar, e.g., when folding an // iPhone Duo @@ -69,6 +70,7 @@ final class TGSheetHostViewController: UIViewController { host.cardController?.updateSheetBarItems() } } +#endif } // MARK: - Appearance From 6f96ad0a4e4c48f090202a6c5cfa47a4319c7770 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 18:14:39 +1100 Subject: [PATCH 16/35] ci: build the Example for any simulator The runner no longer has the named simulator, so the job failed before building anything. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/swift.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From d43384ad880087f9738320a79c78cd7b9ed2d82a Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 18:40:58 +1100 Subject: [PATCH 17/35] fix: keep building for visionOS and Mac Catalyst visionOS has no sheet detents, and neither visionOS nor Mac Catalyst have the vertical bar APIs. Cards never use a system sheet there, so leave those parts out. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController+SystemSheet.swift | 20 +++++++++++++++++-- .../TGSheetHostViewController.swift | 6 ++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index e438219..7ae1f64 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -175,6 +175,7 @@ extension TGCardViewController { 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 @@ -183,6 +184,7 @@ extension TGCardViewController { sheetTargetPosition = position applySheetConfiguration(to: sheet, selecting: position) } +#endif // 3. Hand over the chrome updateCardChromeForPresentationStyle() @@ -368,7 +370,7 @@ extension TGCardViewController { /// is over the map, and another one on the sheet, where the sheet covers the /// bar; see `syncSheetBarItems(sheetTop:)`. func updateSheetBarItems() { -#if compiler(>=6.4) // Xcode 27 proxy, which has the vertical bar APIs +#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 @@ -701,7 +703,7 @@ final class TGBarItemsView: UIView { /// 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, *) { + if #available(iOS 26.0, visionOS 26.0, *) { #if os(visionOS) group = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) #else @@ -812,6 +814,7 @@ extension TGCardViewController { // MARK: - Detents +#if !os(visionOS) // No detents on visionOS, where cards never use a sheet extension TGCardPosition { var sheetDetentIdentifier: UISheetPresentationController.Detent.Identifier { @@ -824,9 +827,12 @@ extension TGCardPosition { } } +#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 @@ -913,6 +919,8 @@ extension TGCardViewController { } } +#endif + /// Moves the sheet to the provided position, or refreshes it in place. /// /// - Parameters: @@ -921,6 +929,9 @@ extension TGCardViewController { /// - 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 @@ -941,13 +952,18 @@ extension TGCardViewController { 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. diff --git a/Sources/TGCardViewController/TGSheetHostViewController.swift b/Sources/TGCardViewController/TGSheetHostViewController.swift index 63101b5..ad68634 100644 --- a/Sources/TGCardViewController/TGSheetHostViewController.swift +++ b/Sources/TGCardViewController/TGSheetHostViewController.swift @@ -44,7 +44,7 @@ final class TGSheetHostViewController: UIViewController { let view = UIView() view.backgroundColor = .clear view.clipsToBounds = true - if #available(iOS 26.0, *) { + 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()) @@ -62,7 +62,7 @@ final class TGSheetHostViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() -#if compiler(>=6.4) // Xcode 27 proxy, which has the vertical bar APIs +#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 @@ -170,9 +170,11 @@ final class TGSheetHostViewController: UIViewController { 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 From 2184a3b6a6be3f0da5a7e29ffe9ce959f412edfc Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 18:42:07 +1100 Subject: [PATCH 18/35] refactor: name bar actions after the vertical bar `TGCard.barActions` is now `verticalBarActions`, and `showsBarActions` is `showsVerticalBarActions`, which says where they show. Internal names follow suit. Co-Authored-By: Claude Opus 5.5 --- Example/cards/ExampleCityCard.swift | 4 +- README.md | 2 +- .../TGCardViewController+SystemSheet.swift | 84 +++++++++---------- .../TGCardViewController.swift | 14 ++-- .../TGSheetHostViewController.swift | 4 +- .../TGCardViewController/cards/TGCard.swift | 21 ++--- 6 files changed, 65 insertions(+), 64 deletions(-) diff --git a/Example/cards/ExampleCityCard.swift b/Example/cards/ExampleCityCard.swift index 3ffb378..dd3c5f0 100644 --- a/Example/cards/ExampleCityCard.swift +++ b/Example/cards/ExampleCityCard.swift @@ -49,9 +49,9 @@ class ExampleCityCard : TGPlainCard { 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 `showsBarActions` is `false`. + /// would show them itself when `showsVerticalBarActions` is `false`. private func updateBarActions() { - barActions = [ + verticalBarActions = [ UIAction( title: isFavorite ? "Remove Favourite" : "Add Favourite", image: UIImage(systemName: isFavorite ? "star.fill" : "star") diff --git a/README.md b/README.md index 7eeb409..91975eb 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Because the sheet is the card controller's `presentedViewController`: - 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 `barActions` 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 `showsBarActions` to leave those actions out of their own content while they're in the bar. +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 diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index 7ae1f64..6e9ce81 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -206,7 +206,7 @@ extension TGCardViewController { sheetBackgroundView = nil sheetTargetPosition = nil sheetDetentValues = [:] - updateSheetBarItems() + updateVerticalBarItems() let moveCardsBack = { [self] in guard let content = cardWrapperContent else { return assertionFailure() } @@ -348,7 +348,7 @@ extension TGCardViewController { /// Tells the sheet which scroll view to track for scrolling vs. resizing. func updateSheetContentScrollView() { sheetHost?.setContentScrollView(topCardView?.contentScrollView) - updateSheetBarItems() + updateVerticalBarItems() } } @@ -364,28 +364,28 @@ extension TGCardViewController { /// 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 - /// `barActions` follow below it. + /// `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 `syncSheetBarItems(sheetTop:)`. - func updateSheetBarItems() { + /// 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 - updateShowsBarActions(showsBar) + updateShowsVerticalBarActions(showsBar) let closeButtons: [UIButton] let currentCloseButton: UIButton? let actions: [UIAction] - let paging: TGBarItemsView.Paging? + 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.barActions + pageCard.currentCard.barActions + 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) @@ -393,7 +393,7 @@ extension TGCardViewController { } else { currentCloseButton = topCardView?.dismissButton closeButtons = [currentCloseButton].compactMap { $0 } - actions = topCard?.barActions ?? [] + actions = topCard?.verticalBarActions ?? [] paging = nil } @@ -404,11 +404,11 @@ extension TGCardViewController { else { suppressedCloseButtons.forEach { Self.setCloseButton($0, suppressed: false) } suppressedCloseButtons = [] - mapBarItems?.removeFromSuperview() - mapBarItems = nil - sheetBarItems?.removeFromSuperview() - sheetBarItems = nil - barItemsEdge = nil + mapVerticalBarItems?.removeFromSuperview() + mapVerticalBarItems = nil + sheetVerticalBarItems?.removeFromSuperview() + sheetVerticalBarItems = nil + verticalBarItemsEdge = nil return } @@ -420,34 +420,34 @@ extension TGCardViewController { suppressedCloseButtons = closeButtons let edge: NSDirectionalRectEdge = barEdge == .leading ? .leading : .trailing - let mapItems: TGBarItemsView - let sheetItems: TGBarItemsView - if let existingMap = mapBarItems, let existingSheet = sheetBarItems, barItemsEdge == edge, existingMap.superview === view, existingSheet.superview === host.view { + 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 { - mapBarItems?.removeFromSuperview() - sheetBarItems?.removeFromSuperview() + 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: TGBarItemsView.itemSize)) + 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: TGBarItemsView.itemSize), + mapItems.widthAnchor.constraint(equalToConstant: TGVerticalBarItemsView.itemSize), ]) // Positioned to match `mapItems` whenever the sheet lays out sheetItems = makeBarItemsView() host.view.addSubview(sheetItems) - mapBarItems = mapItems - sheetBarItems = sheetItems - barItemsEdge = edge + mapVerticalBarItems = mapItems + sheetVerticalBarItems = sheetItems + verticalBarItemsEdge = edge } let style = topCard?.style ?? .default @@ -457,7 +457,7 @@ extension TGCardViewController { host.view.bringSubviewToFront(sheetItems) view.layoutIfNeeded() - syncSheetBarItems(sheetTop: host.view.convert(host.view.bounds, to: view).minY) + syncVerticalBarItems(sheetTop: host.view.convert(host.view.bounds, to: view).minY) #endif } @@ -468,10 +468,10 @@ extension TGCardViewController { /// it's being dragged. /// /// - Parameter sheetTop: The top of the sheet in this controller's view - func syncSheetBarItems(sheetTop: CGFloat) { + func syncVerticalBarItems(sheetTop: CGFloat) { guard - let mapItems = mapBarItems, - let sheetItems = sheetBarItems, + let mapItems = mapVerticalBarItems, + let sheetItems = sheetVerticalBarItems, let host = sheetHost else { return } @@ -499,9 +499,9 @@ extension TGCardViewController { } } - private func makeBarItemsView() -> TGBarItemsView { - TGBarItemsView { [weak self] in - self?.forwardBarCloseButtonTap() + 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 { @@ -512,18 +512,18 @@ extension TGCardViewController { } } - /// Tells the cards in the stack whether bar actions are shown, so that they - /// can leave them out of their content. - private func updateShowsBarActions(_ shows: Bool) { + /// 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.showsBarActions != shows { - card.showsBarActions = shows + for card in [card] + pages where card.showsVerticalBarActions != shows { + card.showsVerticalBarActions = shows } } } - private func forwardBarCloseButtonTap() { + private func forwardVerticalBarCloseTap() { let closeButton: UIButton? if let pageCard = topCard as? TGPageCard { closeButton = pageCard.currentCard.cardView?.dismissButton @@ -546,7 +546,7 @@ extension TGCardViewController { /// 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 TGBarItemsView: UIView { +final class TGVerticalBarItemsView: UIView { struct Paging { var hasPrevious: Bool @@ -557,8 +557,8 @@ final class TGBarItemsView: UIView { /// in, which matches the system's close buttons static let itemSize: CGFloat = 44 - /// The size that icons of bar actions get scaled to fit in, unless they're - /// symbol images + /// 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) { @@ -638,7 +638,7 @@ final class TGBarItemsView: UIView { /// - 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 bar actions + /// - 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) @@ -1020,7 +1020,7 @@ extension TGCardViewController { // rather than jumping ahead. view.layoutIfNeeded() - syncSheetBarItems(sheetTop: sheetTop) + syncVerticalBarItems(sheetTop: sheetTop) } /// Like the classic card does while dragging, fade the card's content from diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index cf05564..8c5f57b 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -175,16 +175,16 @@ open class TGCardViewController: UIViewController { /// 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 mapBarItems: TGBarItemsView? + 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 sheetBarItems: TGBarItemsView? + var sheetVerticalBarItems: TGVerticalBarItemsView? - var barItemsEdge: NSDirectionalRectEdge? + var verticalBarItemsEdge: NSDirectionalRectEdge? /// The close buttons in the titles of the top card, hidden while the bar /// items stand in for them @@ -1150,7 +1150,7 @@ extension TGCardViewController { updateCardFloatingViewContent(card: top) // Before the new card fades in, so that its own close button doesn't - updateSheetBarItems() + 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 @@ -1368,7 +1368,7 @@ extension TGCardViewController { newView.alpha = 0 // and cross-fade below } } - updateSheetBarItems() + updateVerticalBarItems() // We only animate to the previous position if the card obscures the map updateCardStructure(card: newTop?.view, position: newTop?.lastPosition) @@ -2100,8 +2100,8 @@ extension TGCardViewController { // hide the card and disable all card-based interaction if let sheetHost = self.sheetHost { sheetHost.presentationController?.containerView?.alpha = show ? 1 : 0 - self.mapBarItems?.alpha = show ? 1 : 0 - self.mapBarItems?.isUserInteractionEnabled = show + self.mapVerticalBarItems?.alpha = show ? 1 : 0 + self.mapVerticalBarItems?.isUserInteractionEnabled = show } else { self.panner.isEnabled = show self.cardWrapperShadow?.isUserInteractionEnabled = show diff --git a/Sources/TGCardViewController/TGSheetHostViewController.swift b/Sources/TGCardViewController/TGSheetHostViewController.swift index ad68634..3c76fb6 100644 --- a/Sources/TGCardViewController/TGSheetHostViewController.swift +++ b/Sources/TGCardViewController/TGSheetHostViewController.swift @@ -67,7 +67,7 @@ final class TGSheetHostViewController: UIViewController { // The close button follows the vertical bar, e.g., when folding an // iPhone Duo registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) { (host: TGSheetHostViewController, _: UITraitCollection) in - host.cardController?.updateSheetBarItems() + host.cardController?.updateVerticalBarItems() } } #endif @@ -113,7 +113,7 @@ final class TGSheetHostViewController: UIViewController { } // Only now is it in its final place, which determines the vertical bar - cardController?.updateSheetBarItems() + cardController?.updateVerticalBarItems() } // MARK: - Layout diff --git a/Sources/TGCardViewController/cards/TGCard.swift b/Sources/TGCardViewController/cards/TGCard.swift index a29378b..64d64cd 100644 --- a/Sources/TGCardViewController/cards/TGCard.swift +++ b/Sources/TGCardViewController/cards/TGCard.swift @@ -254,29 +254,30 @@ open class TGCard: UIResponder, TGPreferrableView { public var floatingCardToolBarAlignment: FloatingCardToolBarAlignment = .center /// Secondary actions of the card, e.g., for sharing or adding a favourite, - /// which the card controller can show outside of the card. + /// 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 bar actions show as - /// icon-only buttons in that bar, below the close button, like a navigation - /// bar's items would. Otherwise they're not shown, so the card should then - /// show them itself; `showsBarActions` says which applies. + /// 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 barActions: [UIAction] = [] { - didSet { controller?.updateSheetBarItems() } + public var verticalBarActions: [UIAction] = [] { + didSet { controller?.updateVerticalBarItems() } } - /// Whether the card controller shows the card's `barActions` outside of the - /// card, while it's on top. If so, the card shouldn't show them itself. + /// 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 showsBarActions: Bool = false + @objc public internal(set) dynamic var showsVerticalBarActions: Bool = false /// Builds the card's optional header which will be pinned to the top /// From 7ef0e5a6ea650b61f1c61735a187413cb21f3214 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 19:00:48 +1100 Subject: [PATCH 19/35] fix: only show the expanded background in system sheets The classic card keeps its own look, including its rounded corners, so `TGCardStyle.expandedBackgroundColor` now only applies to the sheet. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController+SystemSheet.swift | 3 -- .../cards/TGCardStyle.swift | 12 +++--- .../cards/TGCardView.swift | 40 ------------------- .../cards/TGPageCardView.swift | 4 -- 4 files changed, 7 insertions(+), 52 deletions(-) diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index 6e9ce81..9dbb1d3 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -299,9 +299,6 @@ extension TGCardViewController { } func applyPresentationStyle(to cardView: TGCardView) { - // The sheet shows the background itself, as the cards stay in its safe area - cardView.isExpandedBackgroundSuppressed = usesSystemSheet - guard #available(iOS 26.0, visionOS 26.0, *) else { return } if usesSystemSheet { diff --git a/Sources/TGCardViewController/cards/TGCardStyle.swift b/Sources/TGCardViewController/cards/TGCardStyle.swift index 48b65da..b920ae3 100644 --- a/Sources/TGCardViewController/cards/TGCardStyle.swift +++ b/Sources/TGCardViewController/cards/TGCardStyle.swift @@ -43,15 +43,17 @@ public struct TGCardStyle { } }() - /// Colour to fade in behind the card as it moves up from collapsed, and - /// which fully shows when the card is peaking or extended. Defaults to `nil`, - /// i.e., none. + /// 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 - /// from the map, while a collapsed card still shows its material. + /// the sheet's glass from the map, while a collapsed sheet still shows its + /// material. /// - /// - Note: Not used on Mac Catalyst, where cards are always clear. + /// - 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 diff --git a/Sources/TGCardViewController/cards/TGCardView.swift b/Sources/TGCardViewController/cards/TGCardView.swift index bcd87e9..83a8ba3 100644 --- a/Sources/TGCardViewController/cards/TGCardView.swift +++ b/Sources/TGCardViewController/cards/TGCardView.swift @@ -284,48 +284,9 @@ public class TGCardView: TGCornerView, TGPreferrableView { backgroundColor = .clear #else backgroundColor = style.backgroundColor - applyExpandedBackground(style.expandedBackgroundColor) #endif } - /// Shows `TGCardStyle.expandedBackgroundColor` behind everything, faded in - /// along with the content, see `adjustContentAlpha(to:)` - private var expandedBackgroundView: UIView? - - /// Set while the card is in a system sheet, which shows the expanded - /// background itself, across its full width - var isExpandedBackgroundSuppressed = false { - didSet { expandedBackgroundView?.isHidden = isExpandedBackgroundSuppressed } - } - - private func applyExpandedBackground(_ color: UIColor?) { - guard let color else { - expandedBackgroundView?.removeFromSuperview() - expandedBackgroundView = nil - return - } - - let background: UIView - if let existing = expandedBackgroundView { - background = existing - } else { - background = UIView() - background.isUserInteractionEnabled = false - background.isHidden = isExpandedBackgroundSuppressed - background.alpha = contentScrollView?.alpha ?? 0 - background.translatesAutoresizingMaskIntoConstraints = false - insertSubview(background, at: 0) - NSLayoutConstraint.activate([ - background.topAnchor.constraint(equalTo: topAnchor), - background.bottomAnchor.constraint(equalTo: bottomAnchor), - background.leadingAnchor.constraint(equalTo: leadingAnchor), - background.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) - expandedBackgroundView = background - } - background.backgroundColor = color - } - // MARK: - Content view configuration private var forceHideSeparator = false @@ -368,7 +329,6 @@ public class TGCardView: TGCornerView, TGPreferrableView { owningCard?.willAdjustContentAlpha(value) contentScrollView?.alpha = value - expandedBackgroundView?.alpha = value } } diff --git a/Sources/TGCardViewController/cards/TGPageCardView.swift b/Sources/TGCardViewController/cards/TGPageCardView.swift index 5ad161f..0367bce 100644 --- a/Sources/TGCardViewController/cards/TGPageCardView.swift +++ b/Sources/TGCardViewController/cards/TGPageCardView.swift @@ -210,10 +210,6 @@ class TGPageCardView: TGCardView { cardViews.forEach { $0.contentScrollView?.isScrollEnabled = allowScrolling } } - override var isExpandedBackgroundSuppressed: Bool { - didSet { cardViews.forEach { $0.isExpandedBackgroundSuppressed = isExpandedBackgroundSuppressed } } - } - override func adjustContentAlpha(to value: CGFloat) { // not calling super on purpose. cardViews.forEach { $0.adjustContentAlpha(to: value) } From cc996525e78b045170af6d89bcdbbf08c2d06eea Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 20:15:07 +1100 Subject: [PATCH 20/35] fix: keep cards clear of a vertical bar on the leading edge The sheet's safe area covers the vertical bar's column when the bar is on the trailing edge, but not when it's on the leading edge, e.g., on an unfolded iPhone Duo with the app on the left screen. The cards in the sheet now get inset by the bar's column on whichever side it is. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController+SystemSheet.swift | 46 +++++++++++++++++-- .../TGCardViewController.swift | 2 + 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index 9dbb1d3..30ad71f 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -160,16 +160,20 @@ extension TGCardViewController { // 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, which keeps them clear of - // a vertical bar, e.g., on an iPhone Duo. + // 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), - content.leadingAnchor.constraint(equalTo: host.view.safeAreaLayoutGuide.leadingAnchor), - content.trailingAnchor.constraint(equalTo: host.view.safeAreaLayoutGuide.trailingAnchor), + leftConstraint, + rightConstraint, heightConstraint, ]) sheetContentHeightConstraint = heightConstraint + sheetContentLeftConstraint = leftConstraint + sheetContentRightConstraint = rightConstraint // 2. Configure the sheet sheetHost = host @@ -216,6 +220,8 @@ extension TGCardViewController { NSLayoutConstraint.activate(savedCardContentConstraints) savedCardContentConstraints = [] sheetContentHeightConstraint = nil + sheetContentLeftConstraint = nil + sheetContentRightConstraint = nil updateCardChromeForPresentationStyle() } @@ -401,6 +407,9 @@ extension TGCardViewController { else { suppressedCloseButtons.forEach { Self.setCloseButton($0, suppressed: false) } suppressedCloseButtons = [] + if let host = sheetHost { + keepSheetContentClear(ofBarColumn: nil, in: host.view) + } mapVerticalBarItems?.removeFromSuperview() mapVerticalBarItems = nil sheetVerticalBarItems?.removeFromSuperview() @@ -477,6 +486,7 @@ extension TGCardViewController { sheetItems.frame = frame } sheetItems.layoutIfNeeded() + keepSheetContentClear(ofBarColumn: frame, in: host.view) // Groups fade as a whole, so they don't get cut in half for (mapGroup, sheetGroup) in zip(mapItems.groups, sheetItems.groups) { @@ -496,6 +506,34 @@ extension TGCardViewController { } } + /// Insets the cards in the sheet, so that they don't go under the column of + /// the vertical bar. The 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. + /// + /// - Parameters: + /// - column: The bar's items in `hostView`'s coordinates, or `nil` if + /// there's no vertical bar + /// - hostView: The sheet's view + func keepSheetContentClear(ofBarColumn column: CGRect?, in hostView: UIView) { + var left: CGFloat = 0 + var right: CGFloat = 0 + if let column, !column.isEmpty { + let safe = hostView.safeAreaInsets + if column.midX < hostView.bounds.midX { + left = max(0, column.maxX - safe.left) + } else { + right = max(0, hostView.bounds.width - safe.right - column.minX) + } + } + if let constraint = sheetContentLeftConstraint, abs(constraint.constant - left) > 0.5 { + constraint.constant = left + } + if let constraint = sheetContentRightConstraint, abs(constraint.constant - right) > 0.5 { + constraint.constant = right + } + } + private func makeBarItemsView() -> TGVerticalBarItemsView { TGVerticalBarItemsView { [weak self] in self?.forwardVerticalBarCloseTap() diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index 8c5f57b..225a6e3 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -160,6 +160,8 @@ open class TGCardViewController: UIViewController { /// Hosts the cards while they're shown in a system sheet var sheetHost: TGSheetHostViewController? var sheetContentHeightConstraint: NSLayoutConstraint? + var sheetContentLeftConstraint: NSLayoutConstraint? + var sheetContentRightConstraint: NSLayoutConstraint? var savedCardContentConstraints: [NSLayoutConstraint] = [] var savedCardWrapperEffect: UIVisualEffect? var sheetDetentValues: [TGCardPosition: CGFloat] = [:] From f42e2d1372e81297dcb958df26713c46cf566357 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Fri, 9 Oct 2026 22:44:21 +1100 Subject: [PATCH 21/35] feat: show cards in an adaptive system sheet on iOS 27+ only On iOS 27, the sheet is set up once to adapt to the size classes by itself, like Maps': at the bottom and full width in compact width, and 400pt wide on the leading edge otherwise, e.g., on iPad and on an iPhone in landscape. As the sheet no longer needs to make way for the classic card when the size classes change, the controller no longer switches between the two on rotation or resizing. That needs the sheet placement API of iOS 27, so system sheets are now iOS 27+ only, and `PresentationStyle.systemSheet`, which forced them on earlier versions, is gone. iOS 26 and earlier keep the classic card. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +-- .../TGCardViewController+SystemSheet.swift | 94 +++++++++++++------ .../TGCardViewController.swift | 30 +----- .../TGSheetHostViewController.swift | 2 +- 4 files changed, 75 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 91975eb..afd85c9 100644 --- a/README.md +++ b/README.md @@ -72,18 +72,19 @@ Provides a card-based view controller for mapping applications where the card's } ``` -### System sheets on iOS 26+ +### System sheets on iOS 27+ -By default (`presentationStyle = .automatic`), on iOS 26 and later the cards are shown in a system sheet (`UISheetPresentationController`) whenever that's a good fit: +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 | |---|---| -| iPhone in portrait, narrow iPad window (compact width, regular height) | System sheet | -| iPhone in landscape, iPad, Mac Catalyst, `mode = .sidebar` | Classic | +| 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 25 and earlier | Classic | +| iOS 26 and earlier, or built with Xcode 26 and earlier | Classic | -The controller switches between the two as the size classes change, e.g., when rotating. Set `presentationStyle` to `.classic` to opt out, or to `.systemSheet` to also use sheets on iOS 16 to 25. Check `usesSystemSheet` for what's currently in use. +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: diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index 30ad71f..ede6251 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -13,28 +13,27 @@ extension TGCardViewController { /// How a ``TGCardViewController`` presents its cards. public enum PresentationStyle { - /// Uses ``systemSheet`` where it's a good fit, and ``classic`` otherwise. + /// Shows the cards in a system sheet (`UISheetPresentationController`) on + /// iOS 27 and later, and uses ``classic`` otherwise. /// - /// System sheets are used on iOS 26 and later, in ``Mode-swift.enum/floating`` - /// mode, in compact width and regular height (i.e., an iPhone in portrait or a - /// narrow iPad window), and only when the card controller isn't itself presented - /// in a sheet. + /// 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 - - /// Cards are shown in a system sheet (`UISheetPresentationController`) whenever - /// the size classes allow it (compact width and regular height), and in - /// ``Mode-swift.enum/floating`` mode. Falls back to ``classic`` otherwise. - case systemSheet } /// Whether the cards are currently shown in a system sheet. /// - /// This can change during the lifetime of the controller, e.g., when rotating - /// the device. See ``presentationStyle``. + /// 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 } @@ -67,24 +66,14 @@ extension TGCardViewController { extension TGCardViewController { - func wantsSystemSheet(for traits: UITraitCollection) -> Bool { -#if targetEnvironment(macCatalyst) || os(visionOS) - return false -#else - guard mode == .floating else { return false } - - switch presentationStyle { - case .classic: - return false - case .automatic: - guard #available(iOS 26.0, *) else { return false } - case .systemSheet: - break - } - + /// 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 - traits.horizontalSizeClass == .compact, - traits.verticalSizeClass == .regular + #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. @@ -95,6 +84,31 @@ extension TGCardViewController { } 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 } @@ -105,7 +119,7 @@ extension TGCardViewController { func updateSystemSheetPresentation() { guard isViewLoaded else { return } - let wantsSheet = wantsSystemSheet(for: traitCollection) && topCardView != nil + let wantsSheet = wantsSystemSheet && topCardView != nil if wantsSheet, sheetHost == nil { installSystemSheet() } else if !wantsSheet, sheetHost != nil { @@ -184,7 +198,6 @@ extension TGCardViewController { sheet.delegate = host sheet.prefersScrollingExpandsWhenScrolledToEdge = true sheet.prefersEdgeAttachedInCompactHeight = true - sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = false sheetTargetPosition = position applySheetConfiguration(to: sheet, selecting: position) } @@ -195,6 +208,7 @@ extension TGCardViewController { updateForNewPosition(position: position) updateSheetContentScrollView() + configureSheetSizing(host) super.present(host, animated: false) } @@ -1035,6 +1049,24 @@ extension TGCardViewController { } } + // 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 + } + 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. diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index 225a6e3..71b278e 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -149,8 +149,8 @@ open class TGCardViewController: UIViewController { /// 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 26+ where they fit. Check ``usesSystemSheet`` for - /// what's currently used. + /// uses system sheets on iOS 27+. Check ``usesSystemSheet`` for what's + /// currently used. /// /// - Warning: Set before `viewDidLoad` is called. public var presentationStyle: PresentationStyle = .automatic @@ -662,20 +662,6 @@ open class TGCardViewController: UIViewController { }, completion: nil) } - open override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) { - super.willTransition(to: newCollection, with: coordinator) - - // Leave the sheet before the size classes change, as the sheet would - // otherwise go full screen, and get it back once they have changed. - if usesSystemSheet, !wantsSystemSheet(for: newCollection) { - uninstallSystemSheet(animated: false) - } else if !usesSystemSheet, wantsSystemSheet(for: newCollection) { - coordinator.animate(alongsideTransition: nil) { [weak self] _ in - self?.updateSystemSheetPresentation() - } - } - } - override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) @@ -703,14 +689,6 @@ open class TGCardViewController: UIViewController { // the card's contents to be scrollable. Hence, we reenable the scolling. updateCardScrolling(allow: true, view: topCardView) - if previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass - || previousTraitCollection?.verticalSizeClass != traitCollection.verticalSizeClass { - // In case `willTransition` wasn't called, e.g., when embedded - DispatchQueue.main.async { [weak self] in - self?.updateSystemSheetPresentation() - } - } - topCard?.traitCollectionDidChange(previousTraitCollection) } @@ -728,14 +706,14 @@ open class TGCardViewController: UIViewController { // These get pushed the classic way and move into the sheet once we've // appeared. Keep them hidden until then. - isAwaitingSystemSheet = wantsSystemSheet(for: traitCollection) + isAwaitingSystemSheet = wantsSystemSheet initialCards.forEach { push($0, animated: false, allowToNotify: $0 == initialCards.last, completionHandler: nil) } } fixPositioning() - if isVisible, !usesSystemSheet, presentedViewController == nil, topCardView != nil, wantsSystemSheet(for: traitCollection) { + 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 diff --git a/Sources/TGCardViewController/TGSheetHostViewController.swift b/Sources/TGCardViewController/TGSheetHostViewController.swift index 3c76fb6..1ac2076 100644 --- a/Sources/TGCardViewController/TGSheetHostViewController.swift +++ b/Sources/TGCardViewController/TGSheetHostViewController.swift @@ -9,7 +9,7 @@ import UIKit /// Hosts the card stack in a system sheet, when ``TGCardViewController`` presents -/// its cards using ``TGCardViewController/PresentationStyle-swift.enum/systemSheet``. +/// 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 From 4182e8d9337397dd3144f464f1edaa96340c93a4 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Sun, 11 Oct 2026 19:45:01 +1100 Subject: [PATCH 22/35] feat: optionally keep the sheet beside a vertical bar UIKit extends a sheet underneath the vertical bar of an iPhone Duo, which can look bulgy next to the bar's items. With the new `sheetAvoidsVerticalBar`, the view that UIKit lays out the sheet in is narrowed to the space beside the bar, so the sheet sizes itself to that, and the bar's items always sit over the map. The Example app opts in with the `-avoidVerticalBar` launch argument. Co-Authored-By: Claude Opus 5.5 --- Example/ExampleCardViewController.swift | 3 ++ README.md | 2 +- .../TGCardViewController+SystemSheet.swift | 42 +++++++++++++++++++ .../TGCardViewController.swift | 9 ++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/Example/ExampleCardViewController.swift b/Example/ExampleCardViewController.swift index 6711810..0c2d9b3 100644 --- a/Example/ExampleCardViewController.swift +++ b/Example/ExampleCardViewController.swift @@ -33,6 +33,9 @@ class ExampleCardViewController: TGCardViewController { if ProcessInfo.processInfo.arguments.contains("-classicCards") { presentationStyle = .classic } + // Pass `-avoidVerticalBar` to keep the sheet beside the vertical bar on + // an iPhone Duo + sheetAvoidsVerticalBar = ProcessInfo.processInfo.arguments.contains("-avoidVerticalBar") #if targetEnvironment(macCatalyst) mode = .sidebar diff --git a/README.md b/README.md index afd85c9..9dd87f1 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Because the sheet is the card controller's `presentedViewController`: - 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. +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. By default, UIKit extends the sheet underneath the vertical bar; set `sheetAvoidsVerticalBar` to keep it in the space beside the bar instead. ## Specs diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index ede6251..77e60a4 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -390,6 +390,9 @@ extension TGCardViewController { #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 } + // The bar coming or going changes the space beside it + fitSheetBesideVerticalBar() + let barEdge = sheetHost?.traitCollection.verticalBarEdge ?? .unspecified let showsBar = sheetHost != nil && barEdge != .unspecified updateShowsVerticalBarActions(showsBar) @@ -502,6 +505,11 @@ extension TGCardViewController { sheetItems.layoutIfNeeded() keepSheetContentClear(ofBarColumn: frame, in: host.view) + // When the sheet stays beside the bar, the map's items are always in front + let sheetFrame = host.view.convert(host.view.bounds, to: view) + let barFrame = mapItems.frame + let sheetTop = sheetFrame.maxX <= barFrame.minX || sheetFrame.minX >= barFrame.maxX ? .greatestFiniteMagnitude : sheetTop + // 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) @@ -520,6 +528,38 @@ extension TGCardViewController { } } + /// Keeps the sheet beside a vertical bar, if ``sheetAvoidsVerticalBar`` is + /// set, by narrowing the view that UIKit lays out the sheet in, so the sheet + /// sizes itself to the space next to the bar. Touches in the bar's column + /// then go to what's behind the sheet, i.e., the map and the bar's items. + func fitSheetBesideVerticalBar() { + guard + sheetAvoidsVerticalBar, + let host = sheetHost, + let container = host.sheetPresentationController?.containerView, + let window = view.window + else { return } + + var available = window.bounds +#if compiler(>=6.4) && os(iOS) && !targetEnvironment(macCatalyst) + if #available(iOS 27.1, *), traitCollection.verticalBarEdge != .unspecified { + let edge: NSDirectionalRectEdge = traitCollection.verticalBarEdge == .leading ? .leading : .trailing + let guide = view.layoutGuide(for: .bar(onEdge: edge, extent: TGVerticalBarItemsView.itemSize)) + let column = view.convert(guide.layoutFrame, to: window) + if column.midX < window.bounds.midX { + available.size.width = window.bounds.maxX - column.maxX + available.origin.x = column.maxX + } else { + available.size.width = column.minX - window.bounds.minX + } + } +#endif + + if container.frame != available { + container.frame = available + } + } + /// Insets the cards in the sheet, so that they don't go under the column of /// the vertical bar. The 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 @@ -1039,6 +1079,8 @@ extension TGCardViewController { host.view.window === window else { return } + fitSheetBesideVerticalBar() + syncSheetHostAppearance(host) // Content height follows the extended detent diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index 71b278e..04aadad 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -155,6 +155,15 @@ open class TGCardViewController: UIViewController { /// - Warning: Set before `viewDidLoad` is called. public var presentationStyle: PresentationStyle = .automatic + /// Whether the system sheet stays beside a vertical bar, e.g., on an iPhone + /// Duo, rather than extending underneath it, which is what UIKit does by + /// default. Defaults to `false`. + /// + /// The vertical bar's items then always sit over the map, next to the sheet. + /// + /// - Warning: Set before `viewDidLoad` is called. + public var sheetAvoidsVerticalBar: Bool = false + // MARK: System sheet state, see `TGCardViewController+SystemSheet.swift` /// Hosts the cards while they're shown in a system sheet From e8670a3940985145a18bbb6a60e365b2ab58b96f Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Sun, 11 Oct 2026 19:46:51 +1100 Subject: [PATCH 23/35] feat: keep the sheet beside a vertical bar by default `sheetAvoidsVerticalBar` now defaults to `true`. Set it to `false` to let the sheet extend underneath the bar, as UIKit does by itself. The Example app's launch argument for that is now `-sheetUnderVerticalBar`. Co-Authored-By: Claude Opus 5.5 --- Example/ExampleCardViewController.swift | 8 +++++--- README.md | 2 +- Sources/TGCardViewController/TGCardViewController.swift | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Example/ExampleCardViewController.swift b/Example/ExampleCardViewController.swift index 0c2d9b3..9cc8974 100644 --- a/Example/ExampleCardViewController.swift +++ b/Example/ExampleCardViewController.swift @@ -33,9 +33,11 @@ class ExampleCardViewController: TGCardViewController { if ProcessInfo.processInfo.arguments.contains("-classicCards") { presentationStyle = .classic } - // Pass `-avoidVerticalBar` to keep the sheet beside the vertical bar on - // an iPhone Duo - sheetAvoidsVerticalBar = ProcessInfo.processInfo.arguments.contains("-avoidVerticalBar") + // Pass `-sheetUnderVerticalBar` to let the sheet extend underneath the + // vertical bar on an iPhone Duo + if ProcessInfo.processInfo.arguments.contains("-sheetUnderVerticalBar") { + sheetAvoidsVerticalBar = false + } #if targetEnvironment(macCatalyst) mode = .sidebar diff --git a/README.md b/README.md index 9dd87f1..4538cce 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Because the sheet is the card controller's `presentedViewController`: - 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. By default, UIKit extends the sheet underneath the vertical bar; set `sheetAvoidsVerticalBar` to keep it in the space beside the bar instead. +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. The sheet stays in the space beside the bar; set `sheetAvoidsVerticalBar` to `false` to let it extend underneath the bar, which is what UIKit does by itself. ## Specs diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index 04aadad..d0dcbe8 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -157,12 +157,12 @@ open class TGCardViewController: UIViewController { /// Whether the system sheet stays beside a vertical bar, e.g., on an iPhone /// Duo, rather than extending underneath it, which is what UIKit does by - /// default. Defaults to `false`. + /// itself. Defaults to `true`. /// /// The vertical bar's items then always sit over the map, next to the sheet. /// /// - Warning: Set before `viewDidLoad` is called. - public var sheetAvoidsVerticalBar: Bool = false + public var sheetAvoidsVerticalBar: Bool = true // MARK: System sheet state, see `TGCardViewController+SystemSheet.swift` From ef4f35aade425893a00b20a6c4534b5acbab5111 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Mon, 12 Oct 2026 11:16:50 +1100 Subject: [PATCH 24/35] revert: let the sheet extend underneath a vertical bar again Drops `sheetAvoidsVerticalBar`, going back to UIKit's own sheet layout next to the vertical bar of an iPhone Duo. Co-Authored-By: Claude Opus 5.5 --- Example/ExampleCardViewController.swift | 5 --- README.md | 2 +- .../TGCardViewController+SystemSheet.swift | 42 ------------------- .../TGCardViewController.swift | 9 ---- 4 files changed, 1 insertion(+), 57 deletions(-) diff --git a/Example/ExampleCardViewController.swift b/Example/ExampleCardViewController.swift index 9cc8974..6711810 100644 --- a/Example/ExampleCardViewController.swift +++ b/Example/ExampleCardViewController.swift @@ -33,11 +33,6 @@ class ExampleCardViewController: TGCardViewController { if ProcessInfo.processInfo.arguments.contains("-classicCards") { presentationStyle = .classic } - // Pass `-sheetUnderVerticalBar` to let the sheet extend underneath the - // vertical bar on an iPhone Duo - if ProcessInfo.processInfo.arguments.contains("-sheetUnderVerticalBar") { - sheetAvoidsVerticalBar = false - } #if targetEnvironment(macCatalyst) mode = .sidebar diff --git a/README.md b/README.md index 4538cce..afd85c9 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Because the sheet is the card controller's `presentedViewController`: - 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. The sheet stays in the space beside the bar; set `sheetAvoidsVerticalBar` to `false` to let it extend underneath the bar, which is what UIKit does by itself. +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 diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index 77e60a4..ede6251 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -390,9 +390,6 @@ extension TGCardViewController { #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 } - // The bar coming or going changes the space beside it - fitSheetBesideVerticalBar() - let barEdge = sheetHost?.traitCollection.verticalBarEdge ?? .unspecified let showsBar = sheetHost != nil && barEdge != .unspecified updateShowsVerticalBarActions(showsBar) @@ -505,11 +502,6 @@ extension TGCardViewController { sheetItems.layoutIfNeeded() keepSheetContentClear(ofBarColumn: frame, in: host.view) - // When the sheet stays beside the bar, the map's items are always in front - let sheetFrame = host.view.convert(host.view.bounds, to: view) - let barFrame = mapItems.frame - let sheetTop = sheetFrame.maxX <= barFrame.minX || sheetFrame.minX >= barFrame.maxX ? .greatestFiniteMagnitude : sheetTop - // 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) @@ -528,38 +520,6 @@ extension TGCardViewController { } } - /// Keeps the sheet beside a vertical bar, if ``sheetAvoidsVerticalBar`` is - /// set, by narrowing the view that UIKit lays out the sheet in, so the sheet - /// sizes itself to the space next to the bar. Touches in the bar's column - /// then go to what's behind the sheet, i.e., the map and the bar's items. - func fitSheetBesideVerticalBar() { - guard - sheetAvoidsVerticalBar, - let host = sheetHost, - let container = host.sheetPresentationController?.containerView, - let window = view.window - else { return } - - var available = window.bounds -#if compiler(>=6.4) && os(iOS) && !targetEnvironment(macCatalyst) - if #available(iOS 27.1, *), traitCollection.verticalBarEdge != .unspecified { - let edge: NSDirectionalRectEdge = traitCollection.verticalBarEdge == .leading ? .leading : .trailing - let guide = view.layoutGuide(for: .bar(onEdge: edge, extent: TGVerticalBarItemsView.itemSize)) - let column = view.convert(guide.layoutFrame, to: window) - if column.midX < window.bounds.midX { - available.size.width = window.bounds.maxX - column.maxX - available.origin.x = column.maxX - } else { - available.size.width = column.minX - window.bounds.minX - } - } -#endif - - if container.frame != available { - container.frame = available - } - } - /// Insets the cards in the sheet, so that they don't go under the column of /// the vertical bar. The 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 @@ -1079,8 +1039,6 @@ extension TGCardViewController { host.view.window === window else { return } - fitSheetBesideVerticalBar() - syncSheetHostAppearance(host) // Content height follows the extended detent diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index d0dcbe8..71b278e 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -155,15 +155,6 @@ open class TGCardViewController: UIViewController { /// - Warning: Set before `viewDidLoad` is called. public var presentationStyle: PresentationStyle = .automatic - /// Whether the system sheet stays beside a vertical bar, e.g., on an iPhone - /// Duo, rather than extending underneath it, which is what UIKit does by - /// itself. Defaults to `true`. - /// - /// The vertical bar's items then always sit over the map, next to the sheet. - /// - /// - Warning: Set before `viewDidLoad` is called. - public var sheetAvoidsVerticalBar: Bool = true - // MARK: System sheet state, see `TGCardViewController+SystemSheet.swift` /// Hosts the cards while they're shown in a system sheet From 428a67216bd4b8d5232b23eb344f3ce8f9a906e8 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Mon, 12 Oct 2026 11:19:21 +1100 Subject: [PATCH 25/35] feat: use the full width for collapsed cards next to a vertical bar When the sheet is collapsed, it only shows the card's title, which now uses the sheet's full width rather than staying clear of the vertical bar, and the card's actions aren't shown in the bar. They fade in with the card's content as the sheet rises towards peaking. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController+SystemSheet.swift | 50 ++++++++++++++----- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index ede6251..c24eb6c 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -500,7 +500,12 @@ extension TGCardViewController { sheetItems.frame = frame } sheetItems.layoutIfNeeded() - keepSheetContentClear(ofBarColumn: frame, in: host.view) + + // When collapsed, the sheet only shows the card's title, which can then + // use the full width, and the card's actions aren't shown in the bar + let expansion = sheetExpansion(forSheetTop: sheetTop) ?? 1 + let isCollapsed = expansion < 0.01 + keepSheetContentClear(ofBarColumn: frame, in: host.view, isCollapsed: isCollapsed) // Groups fade as a whole, so they don't get cut in half for (mapGroup, sheetGroup) in zip(mapItems.groups, sheetItems.groups) { @@ -518,6 +523,14 @@ extension TGCardViewController { 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 + } } /// Insets the cards in the sheet, so that they don't go under the column of @@ -529,12 +542,17 @@ extension TGCardViewController { /// - column: The bar's items in `hostView`'s coordinates, or `nil` if /// there's no vertical bar /// - hostView: The sheet's view - func keepSheetContentClear(ofBarColumn column: CGRect?, in hostView: UIView) { + /// - 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 hostView: UIView, isCollapsed: Bool = false) { var left: CGFloat = 0 var right: CGFloat = 0 if let column, !column.isEmpty { let safe = hostView.safeAreaInsets - if column.midX < hostView.bounds.midX { + if isCollapsed { + left = -safe.left + right = -safe.right + } else if column.midX < hostView.bounds.midX { left = max(0, column.maxX - safe.left) } else { right = max(0, hostView.bounds.width - safe.right - column.minX) @@ -663,7 +681,8 @@ final class TGVerticalBarItemsView: UIView { private let nextButton: UIButton private let pagingGroup: UIView private let actionsStack: UIStackView - private let actionsGroup: UIView + /// The group with the card's actions, at the bottom + let actionsGroup: UIView private var actionButtons: [UIButton] = [] private var actions: [UIAction] = [] @@ -1094,15 +1113,7 @@ extension TGCardViewController { /// 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 collapsedHeight = sheetHeight(for: .collapsed), - let peakingHeight = sheetHeight(for: .peaking), - peakingHeight > collapsedHeight - else { return } - - let height = view.bounds.height - sheetTop - let alpha = min(1, max(0, (height - collapsedHeight) / (peakingHeight - collapsedHeight))) + 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 { @@ -1111,6 +1122,19 @@ extension TGCardViewController { 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 = view.bounds.height - sheetTop + return min(1, max(0, (height - collapsedHeight) / (peakingHeight - collapsedHeight))) + } + /// Shows the top card's `expandedBackgroundColor` across the whole sheet private func updateSheetBackground(alpha: CGFloat) { guard let host = sheetHost, let color = topCard?.style.expandedBackgroundColor else { From 8949cf2b09046315c9636feb1bd4534ac317fae9 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Mon, 12 Oct 2026 11:32:07 +1100 Subject: [PATCH 26/35] feat: let the bottom map buttons use the bar's column when collapsed While the sheet is collapsed next to a vertical bar on the trailing edge, the bottom map buttons now ignore the bar's safe area inset, so they line up with the collapsed sheet below them, rather than stopping short of the bar. Above that, they stay clear of the bar as before. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController+SystemSheet.swift | 19 +++++++++++++++++++ .../TGCardViewController.swift | 12 +++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index c24eb6c..e49bf2c 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -424,6 +424,7 @@ extension TGCardViewController { if let host = sheetHost { keepSheetContentClear(ofBarColumn: nil, in: host.view) } + letBottomFloatingViewIgnoreVerticalBar(false) mapVerticalBarItems?.removeFromSuperview() mapVerticalBarItems = nil sheetVerticalBarItems?.removeFromSuperview() @@ -506,6 +507,7 @@ extension TGCardViewController { let expansion = sheetExpansion(forSheetTop: sheetTop) ?? 1 let isCollapsed = expansion < 0.01 keepSheetContentClear(ofBarColumn: frame, in: host.view, isCollapsed: isCollapsed) + letBottomFloatingViewIgnoreVerticalBar(isCollapsed && !isBarOnLeadingEdge(frame, in: host.view)) // Groups fade as a whole, so they don't get cut in half for (mapGroup, sheetGroup) in zip(mapItems.groups, sheetItems.groups) { @@ -533,6 +535,23 @@ extension TGCardViewController { } } + 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() + } + } + /// Insets the cards in the sheet, so that they don't go under the column of /// the vertical bar. The 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 diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index 71b278e..e813095 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -188,6 +188,11 @@ open class TGCardViewController: UIViewController { 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 + /// The close buttons in the titles of the top card, hidden while the bar /// items stand in for them var suppressedCloseButtons: [UIButton] = [] @@ -2270,7 +2275,7 @@ extension TGCardViewController { } } - private func updateFloatingViewsConstraints() { + func updateFloatingViewsConstraints() { if cardIsNextToMap(in: traitCollection) { bottomFloatingViewBottomConstraint.constant = deviceIsiPhoneX() ? 0 : 8 bottomFloatingViewTrailingToSafeAreaConstraint.constant = deviceIsiPhoneX() ? 0 : 8 @@ -2282,6 +2287,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]) { From 652dd14919fd8d94764f39ffdf586ca0d2eee11c Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Mon, 12 Oct 2026 11:54:39 +1100 Subject: [PATCH 27/35] fix: apply the collapsed layout next to a vertical bar without bar items The full-width content and the bottom floating views ignoring the bar only kicked in when the top card had vertical bar items, so cards without any (e.g. TripGo's home card) kept the bar inset when collapsed. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController+SystemSheet.swift | 41 ++++++++++++++++--- .../TGCardViewController.swift | 3 ++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index e49bf2c..2a3d885 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -422,9 +422,10 @@ extension TGCardViewController { suppressedCloseButtons.forEach { Self.setCloseButton($0, suppressed: false) } suppressedCloseButtons = [] if let host = sheetHost { - keepSheetContentClear(ofBarColumn: nil, in: host.view) + updateLayoutForVerticalBar(sheetTop: host.view.convert(host.view.bounds, to: view).minY) + } else { + letBottomFloatingViewIgnoreVerticalBar(false) } - letBottomFloatingViewIgnoreVerticalBar(false) mapVerticalBarItems?.removeFromSuperview() mapVerticalBarItems = nil sheetVerticalBarItems?.removeFromSuperview() @@ -490,6 +491,8 @@ extension TGCardViewController { /// /// - 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, @@ -502,12 +505,9 @@ extension TGCardViewController { } sheetItems.layoutIfNeeded() - // When collapsed, the sheet only shows the card's title, which can then - // use the full width, and the card's actions aren't shown in the bar + // When collapsed, the card's actions aren't shown in the bar let expansion = sheetExpansion(forSheetTop: sheetTop) ?? 1 let isCollapsed = expansion < 0.01 - keepSheetContentClear(ofBarColumn: frame, in: host.view, isCollapsed: isCollapsed) - letBottomFloatingViewIgnoreVerticalBar(isCollapsed && !isBarOnLeadingEdge(frame, in: host.view)) // Groups fade as a whole, so they don't get cut in half for (mapGroup, sheetGroup) in zip(mapItems.groups, sheetItems.groups) { @@ -535,6 +535,35 @@ extension TGCardViewController { } } + /// 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.view, 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 diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index e813095..9b25a42 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -192,6 +192,9 @@ open class TGCardViewController: UIViewController { /// 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 From ef04d0d7499fd72946b980e77a0b7b7948e5e45e Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Mon, 12 Oct 2026 12:00:48 +1100 Subject: [PATCH 28/35] fix: measure the sheet's height from its own bottom edge iOS 27 floats taller collapsed sheets a few points above the bottom of the screen, so measuring from the screen's bottom left them slightly "expanded": vertical bar actions stayed faintly visible and the collapsed layout didn't kick in. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController+SystemSheet.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index 2a3d885..9e7e176 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -1179,10 +1179,17 @@ extension TGCardViewController { peakingHeight > collapsedHeight else { return nil } - let height = view.bounds.height - sheetTop + 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 private func updateSheetBackground(alpha: CGFloat) { guard let host = sheetHost, let color = topCard?.style.expandedBackgroundColor else { @@ -1226,8 +1233,8 @@ extension TGCardViewController { let extendedHeight = sheetHeight(for: .extended) else { return } - let peakingTop = view.bounds.height - peakingHeight - let extendedTop = view.bounds.height - extendedHeight + 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))) From c2630077a2cff679132ccf4b5a965a3bc48b3885 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Mon, 12 Oct 2026 14:18:58 +1100 Subject: [PATCH 29/35] feat: let cards span the sheet under a vertical bar Cards in a system sheet now span its full width, with the sheet host's safe area covering the vertical bar's column, so that only backgrounds and the scroll indicator go under the bar, like in Apple's apps. Titles and plain card content stay within the safe area, as do cell contents. Pages of a page card are hosted in child view controllers, as UIKit only passes a scroll view's safe area and layout margins on to its content at the edges of that content. This also gives inset grouped tables on pages their margins back, e.g., in the trip overview, in both presentations. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController+SystemSheet.swift | 48 +++++++---- .../cards/TGCardView.swift | 56 +++++++++++++ .../cards/TGPageCardView.swift | 82 +++++++++++++++++-- .../cards/TGPlainCardView.swift | 30 ++++++- 4 files changed, 192 insertions(+), 24 deletions(-) diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index 9e7e176..eaeae4f 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -560,7 +560,7 @@ extension TGCardViewController { // 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.view, isCollapsed: isCollapsed) + keepSheetContentClear(ofBarColumn: column, in: host, isCollapsed: isCollapsed) letBottomFloatingViewIgnoreVerticalBar(isCollapsed && column.map { !isBarOnLeadingEdge($0, in: host.view) } ?? false) } @@ -581,31 +581,49 @@ extension TGCardViewController { } } - /// Insets the cards in the sheet, so that they don't go under the column of - /// the vertical bar. The 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. + /// 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 `hostView`'s coordinates, or `nil` if + /// - column: The bar's items in `host`'s view's coordinates, or `nil` if /// there's no vertical bar - /// - hostView: The sheet's view + /// - 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 hostView: UIView, isCollapsed: Bool = false) { + 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 { - let safe = hostView.safeAreaInsets - if isCollapsed { - left = -safe.left - right = -safe.right - } else if column.midX < hostView.bounds.midX { - left = max(0, column.maxX - safe.left) + if column.midX < hostView.bounds.midX { + safeLeft = isCollapsed ? 0 : max(systemLeft, column.maxX) + left = -safeLeft } else { - right = max(0, hostView.bounds.width - safe.right - column.minX) + safeRight = isCollapsed ? 0 : max(systemRight, hostView.bounds.width - column.minX) + right = -safeRight } } + + 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 + } if let constraint = sheetContentLeftConstraint, abs(constraint.constant - left) > 0.5 { constraint.constant = left } diff --git a/Sources/TGCardViewController/cards/TGCardView.swift b/Sources/TGCardViewController/cards/TGCardView.swift index 83a8ba3..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. diff --git a/Sources/TGCardViewController/cards/TGPageCardView.swift b/Sources/TGCardViewController/cards/TGPageCardView.swift index 0367bce..d9ddc6d 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,38 @@ 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 + } + + 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 + 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 +100,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 +159,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 +173,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 +402,36 @@ 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 + + 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 + } + +} 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) + } + } + } From dec62660348ebe8eff02069e2ae8a22f8d4178fd Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Mon, 12 Oct 2026 14:40:04 +1100 Subject: [PATCH 30/35] fix: lay out the cards again after the vertical bar inset changes The sheet's safe area and the cards' constraints are updated while the sheet lays out, after the cards in it are laid out already. When that was the last layout pass, e.g., when the sheet settled after a drag, the cards kept the previous inset, such as reserving the bar's column while collapsed, until the next drag. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController+SystemSheet.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index eaeae4f..5e7ba8b 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -618,17 +618,32 @@ extension TGCardViewController { } } + 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() + } } } From b21075a0c35746a863332c826c3b6d148e57e7b6 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Mon, 12 Oct 2026 14:50:18 +1100 Subject: [PATCH 31/35] feat: span the header across the top above a system sheet In compact width, the header above a system sheet now spans the full width of the screen with square corners, like a toolbar, rather than a rounded panel the width of the card. Its content gets some more room around it, and stays within the safe area, e.g., clear of a vertical bar. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController.swift | 69 +++++++++++++++++-- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index 9b25a42..ce2f234 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -108,6 +108,10 @@ open class TGCardViewController: UIViewController { /// 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, when the header spans the + /// top of the screen 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. @@ -162,6 +166,10 @@ open class TGCardViewController: UIViewController { 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] = [] var savedCardContentConstraints: [NSLayoutConstraint] = [] var savedCardWrapperEffect: UIVisualEffect? var sheetDetentValues: [TGCardPosition: CGFloat] = [:] @@ -2357,6 +2365,7 @@ extension TGCardViewController { headerView.subviews .compactMap { $0 as? TGHeaderView } .forEach { applyCornerStyle(to: $0) } + updateHeaderForSheet() updateStatusBar(headerIsVisible: isShowingHeader) @@ -2388,7 +2397,9 @@ 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 cardIsNextToMap(in: traitCollection) { headerViewHeightConstraint.constant = contentSize.height } else { headerViewHeightConstraint.constant = contentSize.height + topOverlap @@ -2465,9 +2476,59 @@ 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 = headerSpansTopAboveSheet ? 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. + private func updateHeaderForSheet() { + guard let superview = headerView.superview else { return } + let spansTop = headerSpansTopAboveSheet + + 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), + ] + } + if spansTop { + NSLayoutConstraint.deactivate(headerCardEdgeConstraints) + NSLayoutConstraint.activate(headerScreenEdgeConstraints) + headerView.layer.cornerRadius = 0 + headerView.subviews + .compactMap { $0 as? TGHeaderView } + .forEach { $0.layer.cornerRadius = 0 } + } else { + NSLayoutConstraint.deactivate(headerScreenEdgeConstraints) + NSLayoutConstraint.activate(headerCardEdgeConstraints) + } + +#if !os(visionOS) + if #available(iOS 26.0, *) { + let radius = UICornerRadius.fixed(spansTop ? 0 : Constants.iOS26CornerRadius) + headerEffectView.cornerConfiguration = .corners( + topLeftRadius: nil, + topRightRadius: nil, + bottomLeftRadius: radius, + bottomRightRadius: radius + ) + } +#endif } private func updateStatusBar(headerIsVisible: Bool, preferredStyle: UIStatusBarStyle? = nil) { From 316fe0001c957ed8652142157dd7ad57ea4de0aa Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Mon, 12 Oct 2026 15:08:47 +1100 Subject: [PATCH 32/35] feat: align the header with a system sheet on the side When the system sheet is on the side of the screen, e.g., on iPad or an unfolded iPhone, the header now floats right above it as a matching rounded panel: aligned with the sheet's edges, as far from the top as the sheet is from the side, and with the same room around its content. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController+SystemSheet.swift | 1 + .../TGCardViewController.swift | 99 ++++++++++++++++--- 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index 5e7ba8b..c6e270c 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -1162,6 +1162,7 @@ extension TGCardViewController { if mapViewController.additionalSafeAreaInsets != insets { mapViewController.additionalSafeAreaInsets = insets } + alignHeader(withSheetFrame: sheetFrame) fadeCardContent(forSheetTop: sheetFrame.minY) syncVerticalBarItems(sheetTop: sheetFrame.minY) return diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index ce2f234..20065dc 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -108,8 +108,7 @@ open class TGCardViewController: UIViewController { /// 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, when the header spans the - /// top of the screen above a system sheet + /// 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 @@ -170,6 +169,12 @@ open class TGCardViewController: UIViewController { /// when above a system sheet; see `updateHeaderForSheet` var headerCardEdgeConstraints: [NSLayoutConstraint] = [] var headerScreenEdgeConstraints: [NSLayoutConstraint] = [] + 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] = [:] @@ -2388,7 +2393,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 @@ -2399,6 +2406,8 @@ extension TGCardViewController { let contentSize = content.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) 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 { @@ -2476,7 +2485,7 @@ extension TGCardViewController { headerView.addSubview(content) let contentSize = content.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) content.heightAnchor.constraint(equalToConstant: contentSize.height).isActive = true - let inset = headerSpansTopAboveSheet ? Constants.sheetHeaderContentInset : 0 + 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 @@ -2491,9 +2500,15 @@ extension TGCardViewController { /// 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] @@ -2505,25 +2520,50 @@ extension TGCardViewController { 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 { - NSLayoutConstraint.deactivate(headerCardEdgeConstraints) - NSLayoutConstraint.activate(headerScreenEdgeConstraints) headerView.layer.cornerRadius = 0 headerView.subviews .compactMap { $0 as? TGHeaderView } .forEach { $0.layer.cornerRadius = 0 } - } else { - NSLayoutConstraint.deactivate(headerScreenEdgeConstraints) - NSLayoutConstraint.activate(headerCardEdgeConstraints) } #if !os(visionOS) if #available(iOS 26.0, *) { let radius = UICornerRadius.fixed(spansTop ? 0 : Constants.iOS26CornerRadius) + let topRadius = floatsAboveSheet ? radius : nil headerEffectView.cornerConfiguration = .corners( - topLeftRadius: nil, - topRightRadius: nil, + topLeftRadius: topRadius, + topRightRadius: topRadius, bottomLeftRadius: radius, bottomRightRadius: radius ) @@ -2531,6 +2571,43 @@ extension TGCardViewController { #endif } + /// 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) { let headerExtendsToTop = !cardIsNextToMap(in: traitCollection) let headerCoversStatusBar = headerIsVisible && headerExtendsToTop From 4659e857ad65a495580983fdd3e3344be63cc7cc Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Mon, 12 Oct 2026 15:15:40 +1100 Subject: [PATCH 33/35] feat: give the header the same background as the card in the sheet The header above a system sheet now shows the top card's `expandedBackgroundColor` behind its content, fading in and out with the sheet's height exactly like the sheet does, so the header always matches the card below it. Co-Authored-By: Claude Opus 5.5 --- .../TGCardViewController+SystemSheet.swift | 54 ++++++++++++++++++- .../TGCardViewController.swift | 3 ++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift index c6e270c..36917d5 100644 --- a/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift +++ b/Sources/TGCardViewController/TGCardViewController+SystemSheet.swift @@ -222,6 +222,8 @@ extension TGCardViewController { let position = cardPosition sheetHost = nil sheetBackgroundView = nil + headerSheetBackgroundView?.removeFromSuperview() + headerSheetBackgroundView = nil sheetTargetPosition = nil sheetDetentValues = [:] updateVerticalBarItems() @@ -1224,8 +1226,11 @@ extension TGCardViewController { return host.view.convert(host.view.bounds, to: view).maxY } - /// Shows the top card's `expandedBackgroundColor` across the whole sheet + /// 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 @@ -1257,6 +1262,53 @@ extension TGCardViewController { } } + /// 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. diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index 20065dc..c515a14 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -169,6 +169,8 @@ open class TGCardViewController: UIViewController { /// 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] = [] @@ -2569,6 +2571,7 @@ extension TGCardViewController { ) } #endif + matchHeaderBackgroundCorners() } /// Whether the header floats right above a system sheet that's on the side From 44df5021557d34932e1a30972b5ef92e4c58bdf9 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Mon, 12 Oct 2026 15:25:07 +1100 Subject: [PATCH 34/35] feat: round the floating header's top corners concentrically A header that floats above a system sheet on the side now has top corners concentric with the screen's, rather than a fixed radius that clashes with the display's rounded corners. Co-Authored-By: Claude Opus 5.5 --- Sources/TGCardViewController/TGCardViewController.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index c515a14..a1bf30e 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -2562,7 +2562,10 @@ extension TGCardViewController { #if !os(visionOS) if #available(iOS 26.0, *) { let radius = UICornerRadius.fixed(spansTop ? 0 : Constants.iOS26CornerRadius) - let topRadius = floatsAboveSheet ? radius : nil + // 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, From e4be4c77ac54387c3c90cb2ac5693e5a4fd2438e Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Mon, 12 Oct 2026 15:45:48 +1100 Subject: [PATCH 35/35] fix: keep all pages clear of a vertical bar on the leading edge The hosted pages of a page card get the system's safe area, but not what the sheet adds for a vertical bar on the leading edge, e.g., when the app is on the left screen of an unfolded iPhone Duo. Only the first page got it, through the pager's edge. Each page's host now makes up the difference. Co-Authored-By: Claude Opus 5.5 --- .../cards/TGPageCardView.swift | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/Sources/TGCardViewController/cards/TGPageCardView.swift b/Sources/TGCardViewController/cards/TGPageCardView.swift index d9ddc6d..ef245f9 100644 --- a/Sources/TGCardViewController/cards/TGPageCardView.swift +++ b/Sources/TGCardViewController/cards/TGPageCardView.swift @@ -60,6 +60,23 @@ class TGPageCardView: TGCardView { // 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() { @@ -74,6 +91,7 @@ class TGPageCardView: TGCardView { 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) @@ -411,6 +429,19 @@ 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) @@ -434,4 +465,34 @@ private final class TGPageHostViewController: UIViewController { 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 + } + }