From bb277db0a1300cc6100a01de0ca73b65f9b9d590 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Thu, 18 Jun 2026 09:54:43 +0200 Subject: [PATCH] Add floatingCardToolBarItems for card-attached floating views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `topMapToolBarItems` / `bottomMapToolBarItems` overlay the *map*. There was no way to float a control that stays attached to the *card* and remains visible regardless of the card's drag position. `floatingCardToolBarItems` fills that gap: items are hosted in a stack added to the controller's view, pinned to the bottom of the card's visible area at `.defaultHigh` priority but clamped to the safe area as a required constraint. So they prefer to sit at the card's bottom edge, yet never drop below the screen when the card is collapsed or peeking and its content extends past the bottom. `floatingCardToolBarAlignment` controls horizontal placement — `.center` (default, e.g. a status pill), `.leading` / `.trailing` (e.g. a FAB-style primary action), or `.fill` (a full-width button). The leading/trailing inequality clamps stay active for every alignment, so a wide row never overflows the card. The card-attached items are also (re)populated right after `didBuild`, not just during the push/pop floating-view refresh. Cards commonly configure their toolbar items in `didBuild`, which runs *after* that refresh, so items set there would otherwise be silently dropped. Refresh is idempotent (`populateFloatingView` cleans up first), so the persistent view survives. Co-Authored-By: Claude Opus 4.8 --- .../TGCardViewController.swift | 95 ++++++++++++++++++- .../TGCardViewController/cards/TGCard.swift | 29 +++++- 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index e94d8bf..eb18cd7 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -247,6 +247,25 @@ open class TGCardViewController: UIViewController { private var defaultButtons: [UIView]! + /// 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 = { + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = 8 + stack.alignment = .center + stack.translatesAutoresizingMaskIntoConstraints = false + stack.isHidden = true + return stack + }() + + /// Swappable position constraints for `cardFloatingView`, toggled by + /// `applyCardFloatingAlignment(_:)` per the top card's alignment. + private var cardFloatingCenterXConstraint: NSLayoutConstraint? + private var cardFloatingLeadingConstraint: NSLayoutConstraint? + private var cardFloatingTrailingConstraint: NSLayoutConstraint? + /// Views to overlay on the top-right of the map *for every card*, in addition /// to whatever each card's own `topMapToolBarItems` specifies. Use this for /// controls that should be reachable from any screen — a profile button, a @@ -390,9 +409,59 @@ open class TGCardViewController: UIViewController { cardWrapperShadow.layer.shadowOpacity = 0.16 } + setUpCardFloatingView() + monitorVoiceOverStatus() } + /// Pins `cardFloatingView` to the bottom of the card's visible area, clamped + /// to the safe area: it prefers the card's bottom but never drops below the + /// screen, so it stays visible whatever the card's drag position. + private func setUpCardFloatingView() { + view.addSubview(cardFloatingView) + + let preferCardBottom = cardFloatingView.bottomAnchor.constraint(equalTo: cardWrapperContent.bottomAnchor, constant: -16) + preferCardBottom.priority = .defaultHigh + + // The horizontal position is governed by these three equality constraints, + // toggled per card by `applyCardFloatingAlignment(_:)`. The leading/trailing + // *inequality* clamps below stay active for every alignment, so a wide row + // never overflows the card. + cardFloatingCenterXConstraint = cardFloatingView.centerXAnchor.constraint(equalTo: cardWrapperContent.centerXAnchor) + cardFloatingLeadingConstraint = cardFloatingView.leadingAnchor.constraint(equalTo: cardWrapperContent.leadingAnchor, constant: 16) + cardFloatingTrailingConstraint = cardFloatingView.trailingAnchor.constraint(equalTo: cardWrapperContent.trailingAnchor, constant: -16) + + NSLayoutConstraint.activate([ + cardFloatingView.leadingAnchor.constraint(greaterThanOrEqualTo: cardWrapperContent.leadingAnchor, constant: 16), + cardFloatingView.trailingAnchor.constraint(lessThanOrEqualTo: cardWrapperContent.trailingAnchor, constant: -16), + cardFloatingView.bottomAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16), + preferCardBottom, + ]) + + applyCardFloatingAlignment(.center) + } + + /// Activates the position constraint(s) for the given alignment. `.fill` pins + /// both edges so the row stretches across the card; the others pin a single + /// edge or the centre and let the row size to its content. + private func applyCardFloatingAlignment(_ alignment: TGCard.FloatingCardToolBarAlignment) { + cardFloatingCenterXConstraint?.isActive = false + cardFloatingLeadingConstraint?.isActive = false + cardFloatingTrailingConstraint?.isActive = false + + switch alignment { + case .center: + cardFloatingCenterXConstraint?.isActive = true + case .leading: + cardFloatingLeadingConstraint?.isActive = true + case .trailing: + cardFloatingTrailingConstraint?.isActive = true + case .fill: + cardFloatingLeadingConstraint?.isActive = true + cardFloatingTrailingConstraint?.isActive = true + } + } + private func setupGestures() { // Panner for dragging cards up and down @@ -911,6 +980,11 @@ extension TGCardViewController { // Notify that we have completed building the card view and its header view. top.cardView = cardView top.didBuild(cardView: cardView, headerView: header) + + // Cards commonly configure their toolbar items in `didBuild`, which runs + // *after* the floating-view refresh above. Pick up any card-attached + // floating items now that the card has been built, so they aren't missed. + updateCardFloatingViewContent(card: top) // 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 @@ -1914,6 +1988,9 @@ extension TGCardViewController { cleanUpFloatingView(topFloatingView) } + // Card-attached floating items (pinned to the card, not over the map). + updateCardFloatingViewContent(card: card) + // After contents are updated, we do a round of layout // pass, so the wrappers obtain their correct sizes. topFloatingViewWrapper.setNeedsLayout() @@ -1926,7 +2003,23 @@ extension TGCardViewController { // wrappers' widths, e.g., the circle style. applyToolbarItemStyle() } - + + /// (Re)populates the card-attached floating view from `card`'s + /// `floatingCardToolBarItems`. Safe to call repeatedly; a nil or empty list + /// hides it. Called both during the full floating-view refresh and right + /// after `didBuild`, since cards commonly configure their items there. + private func updateCardFloatingViewContent(card: TGCard?) { + if let cardItems = card?.floatingCardToolBarItems, !cardItems.isEmpty { + populateFloatingView(cardFloatingView, with: cardItems) + applyCardFloatingAlignment(card?.floatingCardToolBarAlignment ?? .center) + cardFloatingView.isHidden = false + view.bringSubviewToFront(cardFloatingView) + } else { + cleanUpFloatingView(cardFloatingView) + cardFloatingView.isHidden = true + } + } + private func updateFloatingViewsConstraints() { if cardIsNextToMap(in: traitCollection) { bottomFloatingViewBottomConstraint.constant = deviceIsiPhoneX() ? 0 : 8 diff --git a/Sources/TGCardViewController/cards/TGCard.swift b/Sources/TGCardViewController/cards/TGCard.swift index b4b1f92..776bec6 100644 --- a/Sources/TGCardViewController/cards/TGCard.swift +++ b/Sources/TGCardViewController/cards/TGCard.swift @@ -225,7 +225,34 @@ open class TGCard: UIResponder, TGPreferrableView { /// /// - warning: items are arranged horizontally public var bottomMapToolBarItems: [UIView]? - + + /// Each card can specify views to float at the bottom of the **card itself** + /// (rather than over the map). They are pinned to the bottom of the card's + /// visible area and clamped to the screen's safe area, so they stay visible + /// at any card position — including when the card is collapsed or peeking and + /// its content extends below the screen. + /// + /// - SeeAlso: `bottomMapToolBarItems`, to overlay on the bottom of the map. + /// - SeeAlso: `floatingCardToolBarAlignment`, to place them along the edge. + /// - warning: items are arranged horizontally + public var floatingCardToolBarItems: [UIView]? + + /// How `floatingCardToolBarItems` are placed horizontally within the card. + /// + /// - `center` (default): centred as a group — suits a status pill. + /// - `leading` / `trailing`: pinned to one edge — e.g. a trailing, + /// FAB-style primary action. + /// - `fill`: stretched across the card's width — e.g. a full-width button. + public enum FloatingCardToolBarAlignment { + case leading, center, trailing, fill + } + + /// Horizontal placement of `floatingCardToolBarItems` within the card. + /// Defaults to `.center`. + /// + /// - SeeAlso: `floatingCardToolBarItems` + public var floatingCardToolBarAlignment: FloatingCardToolBarAlignment = .center + /// Builds the card's optional header which will be pinned to the top /// /// - SeeAlso: `TGPageCard`, which relies on this for its navigation.