diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 1cf5463..98b3d50 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -2,41 +2,41 @@ name: Swift on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: - build_xcode_sonoma: - runs-on: macos-14 + build_xcode_latest: + runs-on: macos-26 steps: - - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: 15.3 # latest-stable - - uses: actions/checkout@v4 - - name: Build TGCardVC - run: xcodebuild -workspace . -scheme TGCardViewController -destination 'platform=iOS Simulator,name=iPhone 14' + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - uses: actions/checkout@v4 + - name: Build TGCardVC + run: xcodebuild -workspace . -scheme TGCardViewController -destination 'platform=iOS Simulator,name=iPhone 17' - build_xcode_ventura: - runs-on: macos-13 + build_xcode_previous: + runs-on: macos-15 steps: - - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: latest-stable - - uses: actions/checkout@v4 - - name: Build TGCardVC - run: xcodebuild -workspace . -scheme TGCardViewController -destination 'platform=iOS Simulator,name=iPhone 14' - + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - uses: actions/checkout@v4 + - name: Build TGCardVC + run: xcodebuild -workspace . -scheme TGCardViewController -destination 'platform=iOS Simulator,name=iPhone 16' + examples: - runs-on: macos-14 + runs-on: macos-15 steps: - - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: 15.3 # latest-stable - - uses: actions/checkout@v4 - - name: Build Example - run: | - cd Example - xcodebuild build -scheme 'Example' -destination 'platform=iOS Simulator,name=iPhone 14' + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - uses: actions/checkout@v4 + - name: Build Example + run: | + cd Example + xcodebuild build -scheme 'Example' -destination 'platform=iOS Simulator,name=iPhone 16' diff --git a/Sources/TGCardViewController/TGButtonPosition.swift b/Sources/TGCardViewController/TGButtonPosition.swift index 3c12925..9599c0a 100644 --- a/Sources/TGCardViewController/TGButtonPosition.swift +++ b/Sources/TGCardViewController/TGButtonPosition.swift @@ -20,9 +20,10 @@ public enum TGButtonPosition { public struct TGButtonStyle { /// Default style is rounded rect, no special tint colour and translucent - public init(shape: TGButtonStyle.Shape = .roundedRect, tintColor: UIColor? = nil, isTranslucent: Bool = true) { + public init(shape: TGButtonStyle.Shape = .roundedRect, tintColor: UIColor? = nil, trackingColor: UIColor? = nil, isTranslucent: Bool = true) { self.shape = shape self.tintColor = tintColor + self.trackingColor = trackingColor self.isTranslucent = isTranslucent } @@ -42,6 +43,9 @@ public struct TGButtonStyle { /// Custom tint colour. Uses default tint colour if set to `nil` public let tintColor: UIColor? - + + /// Prominent colour to pass to tracking button. Uses default tint colour if set to `nil` + public let trackingColor: UIColor? + public let isTranslucent: Bool } diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index 27797eb..314a032 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -155,6 +155,7 @@ open class TGCardViewController: UIViewController { @IBOutlet weak var mapShadow: UIView! @IBOutlet weak var cardWrapperShadow: UIView! @IBOutlet public weak var cardWrapperContent: UIView! + @IBOutlet weak var cardWrapperEffectView: UIVisualEffectView! fileprivate weak var cardTransitionShadow: UIView? @IBOutlet weak var statusBarBlurView: UIVisualEffectView! @IBOutlet weak var topFloatingView: UIStackView! @@ -172,6 +173,8 @@ open class TGCardViewController: UIViewController { @IBOutlet weak var cardWrapperHeightConstraint: NSLayoutConstraint! @IBOutlet weak var cardWrapperDynamicLeadingConstraint: NSLayoutConstraint! @IBOutlet weak var cardWrapperStaticLeadingConstraint: NSLayoutConstraint! + @IBOutlet weak var cardWrapperDynamicTrailingConstraint: NSLayoutConstraint! + @IBOutlet weak var cardWrapperDynamicBottomConstraint: NSLayoutConstraint! // Positioning the header view @IBOutlet weak var headerViewHeightConstraint: NSLayoutConstraint! @@ -190,6 +193,8 @@ open class TGCardViewController: UIViewController { var mapViewController = TGMapViewController() public var mapView: UIView! { mapViewController.mapView } + private var initialScrollOffset: CGFloat = 0 + var panner: UIPanGestureRecognizer! var cardTapper: UITapGestureRecognizer! var mapShadowTapper: UITapGestureRecognizer! @@ -290,10 +295,21 @@ open class TGCardViewController: UIViewController { override open func viewDidLoad() { super.viewDidLoad() + if #available(iOS 26.0, *) { + statusBarBlurView.isHidden = true + } + let cardIsNextToMap = self.cardIsNextToMap(in: traitCollection) + // mode-specific styling TGCornerView.roundedCorners = mode == .floating cardWrapperDynamicLeadingConstraint.isActive = mode == .floating cardWrapperStaticLeadingConstraint.isActive = mode == .sidebar + cardWrapperDynamicTrailingConstraint.isActive = mode == .floating && !cardIsNextToMap + if #available(iOS 26.0, *) { + cardWrapperDynamicBottomConstraint.isActive = mode == .floating + } else { + cardWrapperDynamicBottomConstraint.isActive = false + } toggleCardWrappers(hide: true) sidebarSeparator.backgroundColor = .separator @@ -309,6 +325,30 @@ open class TGCardViewController: UIViewController { mapView.translatesAutoresizingMaskIntoConstraints = false mapViewController.didMove(toParent: self) +#if compiler(>=6.2) // Xcode 26 + if #available(iOS 26.0, *) { + cardWrapperEffectView.effect = UIGlassEffect(style: .regular) + if cardIsNextToMap { + cardWrapperEffectView.cornerConfiguration = .corners(radius: 12) + } else { + cardWrapperEffectView.cornerConfiguration = .corners( + topLeftRadius: 44, + topRightRadius: 44, + bottomLeftRadius: .containerConcentric(minimum: 44), + bottomRightRadius: .containerConcentric(minimum: 44) + ) + } + cardWrapperEffectView.clipsToBounds = true + + // Map floating bar buttons don't need to be styled here, that's + // handled in `applyToolbarItemStyle` + } else { + cardWrapperEffectView.effect = nil + } +#else + cardWrapperEffectView.effect = nil +#endif + setupGestures() // Create the default buttons @@ -453,6 +493,36 @@ open class TGCardViewController: UIViewController { cardWrapperHeightConstraint.constant = extendedMinY * -1 + let cardIsNextToMap = cardIsNextToMap(in: traitCollection) + if #available(iOS 26.0, *) { + if cardIsNextToMap { + cardWrapperEffectView.cornerConfiguration = .corners(radius: 12) + } else { + cardWrapperEffectView.cornerConfiguration = .corners( + topLeftRadius: 44, + topRightRadius: 44, + bottomLeftRadius: .containerConcentric(minimum: 44), + bottomRightRadius: .containerConcentric(minimum: 44) + ) + } + } + + // 1. Deactivate potentially conflicting constraints first + cardWrapperStaticLeadingConstraint.isActive = false + cardWrapperDynamicLeadingConstraint.isActive = false + cardWrapperDynamicTrailingConstraint.isActive = false + + // 2. Restore iOS 26 dynamic padding after trait collection changes (rotation/backgrounding) + updateMapShadow(for: cardPosition) + + // 3. Reactivate the correct constraints based on mode and layout + cardWrapperStaticLeadingConstraint.isActive = mode == .sidebar + cardWrapperDynamicLeadingConstraint.isActive = mode == .floating + cardWrapperDynamicTrailingConstraint.isActive = mode == .floating && !cardIsNextToMap + + // 4. Force layout with consistent state + view.layoutIfNeeded() + // When trait collection changes, try to keep the same card position if let previous = previousCardPosition { // Note: Ideally, we'd determine the direction by whether the available @@ -501,6 +571,7 @@ open class TGCardViewController: UIViewController { statusBarBlurHeightConstraint.constant = topOverlap topCardView?.adjustContentAlpha(to: cardPosition == .collapsed ? 0 : 1) + topCardView?.setSeparatorVisibility(forceHidden: cardPosition == .collapsed) updateFloatingViewsConstraints() updateTopInfoViewConstraints() view.setNeedsUpdateConstraints() @@ -623,6 +694,26 @@ open class TGCardViewController: UIViewController { fileprivate func updateMapShadow(for position: TGCardPosition) { mapShadow.alpha = position == .extended ? Constants.mapShadowVisibleAlpha : 0 mapShadow.isUserInteractionEnabled = position == .extended + + if #available(iOS 26.0, *) { + let background: UIColor = position == .extended ? .systemBackground : .clear + topCardView?.grabHandle?.backgroundColor = background + topCardView?.titleView?.backgroundColor = background + cardWrapperContent.backgroundColor = background + + let cardIsNextToMap = cardIsNextToMap(in: traitCollection) + let padding: CGFloat = switch (position, cardIsNextToMap) { + case (_, true): 12 + case (.extended, _): 0 + case (.peaking, _): 6 + case (.collapsed, _): 22 + } + + cardWrapperDynamicLeadingConstraint.constant = padding + cardWrapperDynamicTrailingConstraint.constant = padding + cardWrapperDynamicBottomConstraint.constant = padding + view.setNeedsUpdateConstraints() + } } private func toggleCardWrappers(hide: Bool, prepareOnly: Bool = false) { @@ -793,7 +884,7 @@ extension TGCardViewController { let cardView = top.buildCardView() cards.append( (top, animateTo.position, cardView) ) - if let cardView = cardView { + if let cardView { cardView.dismissButton?.addTarget(self, action: #selector(closeTapped(sender:)), for: .touchUpInside) let showClose = (delegate != nil || cards.count > 1) && top.showCloseButton cardView.updateDismissButton(show: showClose, isSpringLoaded: navigationButtonsAreSpringLoaded) @@ -804,6 +895,7 @@ extension TGCardViewController { // 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) // This allows us to continuously pull down the card view while its // content is scrolled to the top. Note this only applies when the @@ -814,6 +906,7 @@ extension TGCardViewController { // 4. Place the new view coming, preparing to animate in from the bottom cardView.frame = cardWrapperContent.bounds + cardView.alpha = 0 if animated { let offset = cardView.convert(.init(x: 0, y: mapViewWrapper.frame.maxY), to: cardWrapperShadow).y cardView.frame.origin.y = offset @@ -898,26 +991,27 @@ extension TGCardViewController { // old. Only do that if the previous transition completed, i.e., we didn't // already have such a shadow. - if oldTop != nil, animated, cardTransitionShadow == nil, let cardView = cardView { - let shadow = TGCornerView(frame: cardWrapperContent.bounds) + if oldTop != nil, animated, cardTransitionShadow == nil { + let shadow = TGCornerView(frame: cardWrapperEffectView.bounds) shadow.frame.size.height += 50 // for bounciness shadow.backgroundColor = .black shadow.alpha = 0 - cardWrapperContent.insertSubview(shadow, belowSubview: cardView) + cardWrapperEffectView.contentView.insertSubview(shadow, belowSubview: cardWrapperContent) cardTransitionShadow = shadow } let cardAnimations = { self.toggleCardWrappers(hide: cardView == nil, prepareOnly: true) - guard let cardView = cardView else { return } + guard let cardView else { return } self.updateMapShadow(for: animateTo.position) cardView.frame = self.cardWrapperContent.bounds + cardView.alpha = 1 + oldTop?.view?.alpha = 0 self.cardTransitionShadow?.alpha = 0.15 } if self.mode != .floating { cardAnimations() - oldTop?.view?.alpha = 0 } // In some cases, the cardWrapperShadow frame might already have been @@ -949,7 +1043,6 @@ extension TGCardViewController { completion: { _ in self.updateCardScrolling(allow: animateTo.position == .extended, view: cardView) self.previousCardPosition = animateTo.position - oldTop?.view?.alpha = 0 if notify { oldTop?.card.didDisappear(animated: animated) top.didAppear(animated: animated) @@ -1038,7 +1131,7 @@ extension TGCardViewController { } // 4. Determine and set new position of the card wrapper (relative to header!) - newTop?.view?.alpha = 1 + newTop?.view?.alpha = 0 // We only animate to the previous position if the card obscures the map updateCardStructure(card: newTop?.view, position: newTop?.lastPosition) @@ -1068,11 +1161,11 @@ 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, newTop != nil, cardTransitionShadow == nil, let topView = topView { - let shadow = TGCornerView(frame: cardWrapperContent.bounds) + if animated, newTop != nil, cardTransitionShadow == nil { + let shadow = TGCornerView(frame: cardWrapperEffectView.bounds) shadow.backgroundColor = .black shadow.alpha = 0.15 - cardWrapperContent.insertSubview(shadow, belowSubview: topView) + cardWrapperEffectView.contentView.insertSubview(shadow, belowSubview: cardWrapperContent) cardTransitionShadow = shadow } @@ -1083,11 +1176,14 @@ extension TGCardViewController { topView?.frame.origin.y = self.cardWrapperContent.frame.maxY self.cardTransitionShadow?.alpha = 0 newTop?.view?.adjustContentAlpha(to: animateTo == .collapsed ? 0 : 1) + newTop?.view?.setSeparatorVisibility(forceHidden: animateTo == .collapsed) + + newTop?.view?.alpha = 1 + topView?.alpha = 0 } if mode != .floating { cardAnimations() - topView?.alpha = 0 } UIView.animate( @@ -1113,7 +1209,6 @@ extension TGCardViewController { } // This line did crash in Adrian's simulator but only happens rarely; when?!? topView?.removeFromSuperview() - topView?.alpha = 1 self.cardTransitionShadow?.removeFromSuperview() self.updateForNewPosition(position: animateTo) self.updateResponderChainForNewTopCard() @@ -1359,6 +1454,7 @@ extension TGCardViewController { animations: { self.updateMapShadow(for: snapTo.position) self.topCardView?.adjustContentAlpha(to: snapTo.position == .collapsed ? 0 : 1) + self.topCardView?.setSeparatorVisibility(forceHidden: snapTo.position == .collapsed) self.updateFloatingViewsVisibility(for: snapTo.position) self.view.layoutIfNeeded() self.mapViewController.additionalSafeAreaInsets = mapInset @@ -1464,7 +1560,7 @@ extension TGCardViewController { switchTo(.peaking, direction: .down, animated: true) } - + @objc fileprivate func handleInnerPan(_ recogniser: UIPanGestureRecognizer) { guard @@ -1473,11 +1569,23 @@ extension TGCardViewController { scrollView == topCardView?.contentScrollView, panner.isEnabled, scrollView.refreshControl == nil - else { return } + else { + return + } - let negativity = scrollView.contentOffset.y + scrollView.contentInset.top + let negativity: CGFloat + if #available(iOS 26.0, *), scrollView.contentOffset.y <= 0 { + negativity = (recogniser.translation(in: cardWrapperContent).y - initialScrollOffset) * -1 + } else { + negativity = scrollView.contentOffset.y + scrollView.contentInset.top + } switch (negativity, recogniser.state) { + + + case (_, .began) : + self.initialScrollOffset = scrollView.contentOffset.y + case (0 ..< CGFloat.infinity, _): // Reset the transformation whenever we get back to positive offset scrollView.transform = .identity @@ -1521,9 +1629,11 @@ extension TGCardViewController { // the scroll view appear to stay in place (it's important to not // set the content offset to zero here!) self.mapViewController.additionalSafeAreaInsets = updateCardPosition(y: extendedMinY - negativity) - scrollView.transform = CGAffineTransform(translationX: 0, y: negativity) - scrollView.verticalScrollIndicatorInsets.top = scrollView.contentInset.top + negativity * -1 - + if #unavailable(iOS 26.0) { + scrollView.transform = CGAffineTransform(translationX: 0, y: negativity) + scrollView.verticalScrollIndicatorInsets.top = scrollView.contentInset.top + negativity * -1 + } + default: // Ignore other states such as began, failed, etc. break @@ -1568,6 +1678,7 @@ extension TGCardViewController { animations: { self.updateMapShadow(for: animateTo.position) self.topCardView?.adjustContentAlpha(to: animateTo.position == .collapsed ? 0 : 1) + self.topCardView?.setSeparatorVisibility(forceHidden: animateTo.position == .collapsed) self.updateFloatingViewsVisibility(for: animateTo.position) self.view.layoutIfNeeded() self.mapViewController.additionalSafeAreaInsets = mapInsets @@ -1735,12 +1846,20 @@ extension TGCardViewController { if let customTint = buttonStyle.tintColor { view.tintColor = customTint + } else { + view.tintColor = nil } guard let visualView = view as? UIVisualEffectView else { return assertionFailure() } - if buttonStyle.isTranslucent { + if #available(iOS 26.0, *) { +#if compiler(>=6.2) // Xcode 26 proxy + visualView.effect = UIGlassEffect(style: .regular) +#endif + visualView.layer.borderWidth = 0 + visualView.layer.shadowOpacity = 0 + } else if buttonStyle.isTranslucent { visualView.effect = UIBlurEffect(style: .regular) visualView.layer.borderWidth = 0 visualView.layer.shadowOpacity = 0 @@ -1757,6 +1876,22 @@ extension TGCardViewController { apply(on: topFloatingViewWrapper) apply(on: bottomFloatingViewWrapper) + + if showDefaultButtons, let defaultButtons, let customTint = buttonStyle.trackingColor { + for view in defaultButtons { + for subview in view.subviews { + if let tracker = subview as? MKUserTrackingButton { + tracker.tintColor = customTint + + // For some reason the MKUserTrackingButton's internal doesn't want + // to inherit the tint of the button. So we go in deep. + for internalView in tracker.subviews { + internalView.tintColor = customTint + } + } + } + } + } } public func updateMapToolbarItems() { @@ -1874,7 +2009,12 @@ extension TGCardViewController { private func updateHeaderStyle() { @MainActor func applyCornerStyle(to view: UIView) { - let radius: CGFloat = 16 + let radius: CGFloat + if #available(iOS 26.0, *) { + radius = 22 + } else { + radius = 16 + } let roundAllCorners = cardIsNextToMap(in: traitCollection) view.layer.maskedCorners = roundAllCorners @@ -1892,11 +2032,15 @@ extension TGCardViewController { updateStatusBar(headerIsVisible: isShowingHeader) - // same shadow as for card wrapper - headerView.layer.shadowColor = UIColor.black.cgColor - headerView.layer.shadowOffset = .zero - headerView.layer.shadowRadius = 12 - headerView.layer.shadowOpacity = 0.5 + if #available(iOS 26.0, *) { + // No header. Rely on this being a UIVisualEffectView + } else { + // same shadow as for card wrapper + headerView.layer.shadowColor = UIColor.black.cgColor + headerView.layer.shadowOffset = .zero + headerView.layer.shadowRadius = 12 + headerView.layer.shadowOpacity = 0.5 + } } private func updateHeaderConstraints() { @@ -2054,7 +2198,16 @@ extension TGCardViewController: UIGestureRecognizerDelegate { } else if let pager = (topCardView as? TGPageCardView)?.pager, other == pager.panGestureRecognizer { // When our panner fires, block panning of the page card - return false + if #available(iOS 26.0, *) { + // iOS 26: Allow vertical card dragging when swiping vertically, + // but block it during horizontal swipes to enable clean paging + let velocity = panner.velocity(in: cardWrapperContent) + let swipeHorizontally = abs(velocity.x) > abs(velocity.y) + return !swipeHorizontally + + } else { + return false + } } else { // We don't want to interfere with any existing horizontal swipes, e.g., swipe to delete @@ -2103,7 +2256,14 @@ extension TGCardViewController: UIGestureRecognizerDelegate { scrollView.isScrollEnabled = true } - return false + // iOS 26 and up automatically handles the dragging the outer card while + // we do the inner pan. So we can let it pass. This works in combination + // with the early exist in handleInnerPan. + if #available(iOS 26.0, *) { + return scrollView.contentOffset.y <= 0 + } else { + return false + } } } diff --git a/Sources/TGCardViewController/TGCardViewController.xib b/Sources/TGCardViewController/TGCardViewController.xib index 1f8b506..3cb4642 100644 --- a/Sources/TGCardViewController/TGCardViewController.xib +++ b/Sources/TGCardViewController/TGCardViewController.xib @@ -1,9 +1,9 @@ - - + + - + @@ -16,7 +16,10 @@ + + + @@ -42,29 +45,29 @@ - + - + - + - + - + - + @@ -82,19 +85,19 @@ - + - + - + - + - + @@ -107,7 +110,7 @@ - + @@ -126,7 +129,7 @@ - + @@ -134,13 +137,13 @@ - + - + @@ -164,32 +167,48 @@ - + - - - - + + + + + + + + + + + + + + + + + + + + - + + - - - + + + - - + - + @@ -209,10 +228,9 @@ - - + @@ -222,10 +240,11 @@ - + + @@ -233,6 +252,7 @@ + @@ -255,13 +275,13 @@ - + + - @@ -271,18 +291,18 @@ - + + - - + - + diff --git a/Sources/TGCardViewController/cards/TGCard.swift b/Sources/TGCardViewController/cards/TGCard.swift index b4a176b..8eae12e 100644 --- a/Sources/TGCardViewController/cards/TGCard.swift +++ b/Sources/TGCardViewController/cards/TGCard.swift @@ -58,10 +58,12 @@ open class TGCard: UIResponder, TGPreferrableView { } /// The default image for the close button on a card, with default color + @available(*, deprecated, message: "Use `configureCloseButton` instead.") public static let closeButtonImage = TGCardStyleKit.imageOfCardCloseIcon() /// The default image for the close button on a card, with custom background /// color + @available(*, deprecated, message: "Use `configureCloseButton` instead.") public static func closeButtonImage(background: UIColor) -> UIImage { TGCardStyleKit.imageOfCardCloseIcon(closeButtonBackground: background) } @@ -72,6 +74,7 @@ open class TGCard: UIResponder, TGPreferrableView { /// /// - Parameter style: The style to use /// - Returns: A styled icon for use in a close button on a card + @available(*, deprecated, message: "Use `configureCloseButton` instead.") public static func closeButtonImage(style: TGCardStyle) -> UIImage { TGCardStyleKit.imageOfCardCloseIcon( closeButtonBackground: style.closeButtonBackgroundColor, @@ -79,6 +82,34 @@ open class TGCard: UIResponder, TGPreferrableView { ) } + public static func configureCloseButton(_ button: UIButton, style: TGCardStyle = .default) { + if #available(iOS 26.0, *) { + for state: UIControl.State in [.normal, .highlighted, .selected, .disabled, .focused] { + button.setTitle(nil, for: state) + } + + var config = UIButton.Configuration.glass() + config.title = nil + config.image = UIImage(systemName: "xmark") + config.imagePlacement = .all + config.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 22, weight: .medium) + config.imagePadding = 0 + config.contentInsets = .init(top: 10, leading: 10, bottom: 10, trailing: 10) + config.cornerStyle = .capsule + + button.configuration = config + + } else { + let image = TGCardStyleKit.imageOfCardCloseIcon( + closeButtonBackground: style.closeButtonBackgroundColor, + closeButtonCross: style.closeButtonCrossColor + ) + + button.setImage(image, for: .normal) + button.setTitle(nil, for: .normal) + } + } + /// A default image for an arrow pointing up or down, similar to the close button image public static func arrowButtonImage(direction: TGArrowDirection, background: UIColor, arrow: UIColor) -> UIImage { switch direction { @@ -127,7 +158,9 @@ open class TGCard: UIResponder, TGPreferrableView { } /// The position to display the card in, when pushing - public let initialPosition: TGCardPosition? + /// + /// Should only be modified until the card is first pushed. + public var initialPosition: TGCardPosition? /// Whether the close button should be visible on the card title /// diff --git a/Sources/TGCardViewController/cards/TGCardStyle.swift b/Sources/TGCardViewController/cards/TGCardStyle.swift index 645cca6..6ff5324 100644 --- a/Sources/TGCardViewController/cards/TGCardStyle.swift +++ b/Sources/TGCardViewController/cards/TGCardStyle.swift @@ -13,7 +13,17 @@ public struct TGCardStyle { public static let `default` = TGCardStyle() /// Font to use for title, defaults to bold system font with size 17pt. - public var titleFont: UIFont = .boldSystemFont(ofSize: 17) + public var titleFont: UIFont = { + if #available(iOS 26.0, *) { + if let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .largeTitle).withSymbolicTraits(.traitBold) { + return UIFont.init(descriptor: descriptor, size: descriptor.pointSize) + } else { + return .preferredFont(forTextStyle: .largeTitle) + } + } else { + return .boldSystemFont(ofSize: 17) + } + }() /// Title colour, defaults to system label color public var titleTextColor: UIColor = .label @@ -25,7 +35,13 @@ public struct TGCardStyle { public var subtitleTextColor: UIColor = .secondaryLabel /// Colour to use for the background, defaults to system background color - public var backgroundColor: UIColor = .systemBackground + public var backgroundColor: UIColor = { + if #available(iOS 26.0, *) { + return .clear + } else { + return .systemBackground + } + }() /// 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 adb1a3d..21e97ed 100644 --- a/Sources/TGCardViewController/cards/TGCardView.swift +++ b/Sources/TGCardViewController/cards/TGCardView.swift @@ -242,9 +242,20 @@ public class TGCardView: TGCornerView, TGPreferrableView { // MARK: - Content view configuration + private var forceHideSeparator = false + + func setSeparatorVisibility(forceHidden: Bool) { + forceHideSeparator = forceHidden + // Trigger separator update with current scroll state + if let contentScrollView = contentScrollView { + let actualOffset = contentScrollView.transform.ty < 0 ? 0 : contentScrollView.contentOffset.y + showSeparator(actualOffset > 0, offset: actualOffset) + } + } + func showSeparator(_ show: Bool, offset: CGFloat) { if let owningCard, owningCard.shouldToggleSeparator(show: show, offset: offset) { - contentSeparator?.isHidden = !show + contentSeparator?.isHidden = forceHideSeparator ? true : !show } else if let owningCard, owningCard.title.isExtended, owningCard.autoIgnoreContentInset, let contentScrollView, contentScrollView.isDecelerating, offset < 0 { // This handles the case where you fling the content down further than the diff --git a/Sources/TGCardViewController/cards/TGHostingCard.swift b/Sources/TGCardViewController/cards/TGHostingCard.swift index a4b970f..541a460 100644 --- a/Sources/TGCardViewController/cards/TGHostingCard.swift +++ b/Sources/TGCardViewController/cards/TGHostingCard.swift @@ -1,10 +1,11 @@ // // TGHostingCard.swift -// +// // // Created by Adrian Schönig on 21/4/21. // + import SwiftUI /// A hosting card can be used to use a SwiftUI `View` as the card's content. @@ -15,16 +16,37 @@ import SwiftUI @available(iOS 13.0, *) open class TGHostingCard: TGCard where Content: View { - private let host: UIHostingController + private let host: UIHostingController + private let relay: _TGSizeRelay public init(title: CardTitle, rootView: Content, mapManager: TGCompatibleMapManager? = nil, initialPosition: TGCardPosition? = nil) { - self.host = TGHostingController(rootView: rootView) - + let relay = _TGSizeRelay() + let observedRoot = rootView._tgOnSizeChange { [weak relay] in + relay?.onSize?($0) + } + self.host = UIHostingController(rootView: AnyView(observedRoot)) + self.relay = relay + super.init(title: title, mapManager: mapManager, initialPosition: mapManager != nil ? initialPosition : .extended) + + // After init, connect size changes to intrinsic invalidation so Auto Layout + // updates content height. UIHostingController doesn't manage to reliably + // do that itself, but nudging it this way does the trick. + relay.onSize = { [weak host = self.host] size in + guard let view = host?.view else { return } + view.invalidateIntrinsicContentSize() + view.setNeedsLayout() + } + } + + open func didBuild(scrollView: UIScrollView) { + } + + open func didBuild(scrollView: UIScrollView, cardView: TGCardView) { } // MARK: - Constructing views @@ -32,37 +54,71 @@ open class TGHostingCard: TGCard where Content: View { open override func buildCardView() -> TGCardView? { let view = TGScrollCardView.instantiate(extended: title.isExtended) - host.beginAppearanceTransition(true, animated: false) - let scroller = UIScrollView(frame: .zero) + view.configure(scroller, with: self) + host.beginAppearanceTransition(true, animated: false) host.view.translatesAutoresizingMaskIntoConstraints = false + host.view.backgroundColor = .clear scroller.addSubview(host.view) + NSLayoutConstraint.activate([ - host.view.leadingAnchor.constraint(equalTo: scroller.leadingAnchor), - host.view.topAnchor.constraint(equalTo: scroller.topAnchor), - host.view.trailingAnchor.constraint(equalTo: scroller.trailingAnchor), + host.view.leadingAnchor.constraint(equalTo: scroller.contentLayoutGuide.leadingAnchor), + host.view.topAnchor.constraint(equalTo: scroller.contentLayoutGuide.topAnchor), + host.view.trailingAnchor.constraint(equalTo: scroller.contentLayoutGuide.trailingAnchor), + host.view.bottomAnchor.constraint(equalTo: scroller.contentLayoutGuide.bottomAnchor), + host.view.widthAnchor.constraint(equalTo: scroller.frameLayoutGuide.widthAnchor), ]) + host.endAppearanceTransition() - view.configure(scroller, with: self) + return view + } + + override public final func didBuild(cardView: TGCardView?, headerView: TGHeaderView?) { - host.view.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true + defer { super.didBuild(cardView: cardView, headerView: headerView) } - host.endAppearanceTransition() - return view + guard let cardView, let scrollView = (cardView as? TGScrollCardView)?.embeddedScrollView else { + preconditionFailure() + } + + didBuild(scrollView: scrollView) + didBuild(scrollView: scrollView, cardView: cardView) } } -@available(iOS 13.0, *) -fileprivate class TGHostingController: UIHostingController where Content: View { - - override func viewWillLayoutSubviews() { - super.viewWillLayoutSubviews() - if let scroller = view.superview as? UIScrollView { - let size = sizeThatFits(in: scroller.bounds.size) - scroller.contentSize = size - view.heightAnchor.constraint(equalToConstant: size.height).isActive = true - } + +// MARK: - SwiftUI size reporting helper + +private struct _TGSizeKey: PreferenceKey { + static var defaultValue: CGSize = .zero + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { + value = nextValue() } } + +private struct _TGSizeReader: ViewModifier { + let onChange: (CGSize) -> Void + func body(content: Content) -> some View { + content + .background( + GeometryReader { proxy in + Color.clear + .preference(key: _TGSizeKey.self, value: proxy.size) + .onPreferenceChange(_TGSizeKey.self, perform: onChange) + } + ) + } +} + +private extension View { + func _tgOnSizeChange(_ perform: @escaping (CGSize) -> Void) -> some View { + modifier(_TGSizeReader(onChange: perform)) + } +} + +private final class _TGSizeRelay { + var onSize: ((CGSize) -> Void)? +} + diff --git a/Sources/TGCardViewController/cards/TGPageCardView.swift b/Sources/TGCardViewController/cards/TGPageCardView.swift index 4104de6..0367bce 100644 --- a/Sources/TGCardViewController/cards/TGPageCardView.swift +++ b/Sources/TGCardViewController/cards/TGPageCardView.swift @@ -357,7 +357,7 @@ extension TGPageCardView: UIScrollViewDelegate { visiblePageLogical = logical delegate?.didChangeCurrentPage(to: logical, animated: true) - lastHorizontalOffset = scrollView.contentOffset.y + lastHorizontalOffset = scrollView.contentOffset.x let topMost = cardView(index: logical) self.accessibilityElements = [topMost].compactMap { $0 } diff --git a/Sources/TGCardViewController/cards/TGPageCardView.xib b/Sources/TGCardViewController/cards/TGPageCardView.xib index 4064fb3..db0330b 100644 --- a/Sources/TGCardViewController/cards/TGPageCardView.xib +++ b/Sources/TGCardViewController/cards/TGPageCardView.xib @@ -1,9 +1,9 @@ - + - + @@ -13,7 +13,7 @@ - + diff --git a/Sources/TGCardViewController/cards/TGPageHeaderView.swift b/Sources/TGCardViewController/cards/TGPageHeaderView.swift index 87136c9..06755d5 100644 --- a/Sources/TGCardViewController/cards/TGPageHeaderView.swift +++ b/Sources/TGCardViewController/cards/TGPageHeaderView.swift @@ -13,6 +13,7 @@ public class TGPageHeaderView: TGHeaderView { @IBOutlet weak var accessoryWrapperView: UIView! @IBOutlet weak var accessoryWrapperHeightConstraint: NSLayoutConstraint! + @IBOutlet weak var accessoryLeadingConstraint: NSLayoutConstraint! @IBOutlet weak var accessoryTrailingConstraint: NSLayoutConstraint! @IBOutlet weak var buttonTrailingConstraint: NSLayoutConstraint! @@ -26,6 +27,14 @@ public class TGPageHeaderView: TGHeaderView { override public func awakeFromNib() { super.awakeFromNib() + if #available(iOS 26.0, *) { + accessoryLeadingConstraint.constant = 0 + accessoryTrailingConstraint.constant = 0 + } else { + accessoryLeadingConstraint.constant = 8 + accessoryTrailingConstraint.constant = 8 + } + rightButton?.isHidden = true accessoryWrapperView.isHidden = true preferredStatusBarStyle = .default diff --git a/Sources/TGCardViewController/cards/TGPageHeaderView.xib b/Sources/TGCardViewController/cards/TGPageHeaderView.xib index 0652e1d..bd64d45 100644 --- a/Sources/TGCardViewController/cards/TGPageHeaderView.xib +++ b/Sources/TGCardViewController/cards/TGPageHeaderView.xib @@ -1,9 +1,9 @@ - + - + @@ -45,6 +45,7 @@ + diff --git a/Sources/TGCardViewController/cards/TGPlainCard.swift b/Sources/TGCardViewController/cards/TGPlainCard.swift index 91f4634..7307728 100644 --- a/Sources/TGCardViewController/cards/TGPlainCard.swift +++ b/Sources/TGCardViewController/cards/TGPlainCard.swift @@ -58,7 +58,7 @@ open class TGPlainCard: TGCard { open override func buildCardView() -> TGCardView? { let view = TGPlainCardView.instantiate(extended: title.isExtended) - view.configure(with: self) + view.configure(with: self, contentView: contentView) return view } diff --git a/Sources/TGCardViewController/cards/TGPlainCardView.swift b/Sources/TGCardViewController/cards/TGPlainCardView.swift index bb021ea..de4bc6a 100644 --- a/Sources/TGCardViewController/cards/TGPlainCardView.swift +++ b/Sources/TGCardViewController/cards/TGPlainCardView.swift @@ -25,12 +25,8 @@ class TGPlainCardView: TGCardView { // MARK: - Configuration - override func configure(with card: TGCard) { - guard let plainCard = card as? TGPlainCard else { - preconditionFailure() - } - - super.configure(with: plainCard) + func configure(with card: TGCard, contentView: UIView?) { + super.configure(with: card) // build the header var adjustment: CGFloat = 1.0 // accounted for the separator @@ -48,15 +44,14 @@ class TGPlainCardView: TGCardView { contentViewHeightEqualToSuperviewHeightConstraint.constant = -1*adjustment } - // build the main content - if let content = plainCard.contentView { + if let content = contentView { content.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(content) - content.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true - content.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true - contentView.trailingAnchor.constraint(equalTo: content.trailingAnchor).isActive = true - contentView.bottomAnchor.constraint(equalTo: content.bottomAnchor).isActive = true + 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 } } diff --git a/Sources/TGCardViewController/cards/TGScrollHostingCard.swift b/Sources/TGCardViewController/cards/TGScrollHostingCard.swift new file mode 100644 index 0000000..e69de29 diff --git a/Sources/TGCardViewController/style/TGCardStyleKit.swift b/Sources/TGCardViewController/style/TGCardStyleKit.swift index 00ef421..6947a29 100644 --- a/Sources/TGCardViewController/style/TGCardStyleKit.swift +++ b/Sources/TGCardViewController/style/TGCardStyleKit.swift @@ -190,8 +190,16 @@ class TGCardStyleKit : NSObject { } @objc dynamic class func imageOfCardCloseIcon(closeButtonBackground: UIColor = UIColor(red: 0.130, green: 0.160, blue: 0.200, alpha: 0.080), closeButtonCross: UIColor = UIColor(red: 0.440, green: 0.460, blue: 0.480, alpha: 1.000)) -> UIImage { - UIGraphicsBeginImageContextWithOptions(CGSize(width: 24, height: 24), false, 0) - TGCardStyleKit.drawCardCloseIcon(closeButtonBackground: closeButtonBackground, closeButtonCross: closeButtonCross) + let width: CGFloat + if #available(iOS 26.0, *) { + width = 44 + } else { + width = 24 + } + + let frame = CGRect(origin: .init(x: 0, y: 0), size: CGSize(width: width, height: width)) + UIGraphicsBeginImageContextWithOptions(frame.size, false, 0) + TGCardStyleKit.drawCardCloseIcon(frame: frame, closeButtonBackground: closeButtonBackground, closeButtonCross: closeButtonCross) let imageOfCardCloseIcon = UIGraphicsGetImageFromCurrentImageContext()!.withRenderingMode(.alwaysOriginal) UIGraphicsEndImageContext() @@ -200,8 +208,16 @@ class TGCardStyleKit : NSObject { } @objc dynamic class func imageOfCardArrowIcon(closeButtonBackground: UIColor = UIColor(red: 0.130, green: 0.160, blue: 0.200, alpha: 0.080), closeButtonCross: UIColor = UIColor(red: 0.440, green: 0.460, blue: 0.480, alpha: 1.000), arrowRotation: CGFloat = 0) -> UIImage { - UIGraphicsBeginImageContextWithOptions(CGSize(width: 24, height: 24), false, 0) - TGCardStyleKit.drawCardArrowIcon(closeButtonBackground: closeButtonBackground, closeButtonCross: closeButtonCross, arrowRotation: arrowRotation) + let width: CGFloat + if #available(iOS 26.0, *) { + width = 44 + } else { + width = 24 + } + + let frame = CGRect(origin: .init(x: 0, y: 0), size: CGSize(width: width, height: width)) + UIGraphicsBeginImageContextWithOptions(frame.size, false, 0) + TGCardStyleKit.drawCardArrowIcon(frame: frame, closeButtonBackground: closeButtonBackground, closeButtonCross: closeButtonCross, arrowRotation: arrowRotation) let imageOfCardArrowIcon = UIGraphicsGetImageFromCurrentImageContext()!.withRenderingMode(.alwaysOriginal) UIGraphicsEndImageContext() diff --git a/Sources/TGCardViewController/style/TGCornerView.swift b/Sources/TGCardViewController/style/TGCornerView.swift index f55a957..bedeb99 100644 --- a/Sources/TGCardViewController/style/TGCornerView.swift +++ b/Sources/TGCardViewController/style/TGCornerView.swift @@ -17,9 +17,15 @@ public class TGCornerView: UIView { super.layoutSubviews() if Self.roundedCorners { + let radius: CGFloat + if #available(iOS 26.0, *) { + radius = 44 + } else { + radius = 16 + } let path = UIBezierPath(roundedRect: self.bounds, byRoundingCorners: [.topLeft, .topRight], - cornerRadii: CGSize(width: 16, height: 16)) + cornerRadii: CGSize(width: radius, height: radius)) let mask = CAShapeLayer() mask.path = path.cgPath layer.mask = mask diff --git a/Sources/TGCardViewController/views/TGCardDefaultTitleView.swift b/Sources/TGCardViewController/views/TGCardDefaultTitleView.swift index 3f150af..90d2262 100644 --- a/Sources/TGCardViewController/views/TGCardDefaultTitleView.swift +++ b/Sources/TGCardViewController/views/TGCardDefaultTitleView.swift @@ -17,6 +17,10 @@ class TGCardDefaultTitleView: UIView, TGPreferrableView { @IBOutlet weak var dismissButton: UIButton! @IBOutlet weak var accessoryViewContainer: UIView! + @IBOutlet weak var topLevelTopConstraint: NSLayoutConstraint! + @IBOutlet weak var labelStackLeadingConstraint: NSLayoutConstraint! + @IBOutlet weak var innerTrailingConstraint: NSLayoutConstraint! + // By default, the top level stack snaps to all edges // of the default title view. The space to the bottom // edge is exposed, so that we can allow the accessory @@ -26,6 +30,18 @@ class TGCardDefaultTitleView: UIView, TGPreferrableView { override func awakeFromNib() { super.awakeFromNib() + if #available(iOS 26.0, *) { + topLevelTopConstraint.constant = 0 + labelStackLeadingConstraint.constant = 22 + innerTrailingConstraint.constant = 37 // 9 pixels extra space to the side + + titleLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: 32).isActive = true + } else { + topLevelTopConstraint.constant = 8 + labelStackLeadingConstraint.constant = 16 + innerTrailingConstraint.constant = 28 + } + // Here we set the minimum width and height to provide sufficient hit // target. The priority is lowered because we may need to hide the // button and in such case, stack view will reduce its size to zero, @@ -116,12 +132,7 @@ class TGCardDefaultTitleView: UIView, TGPreferrableView { if isInitial { dismissButton.isHidden = false - let closeButtonImage = TGCardStyleKit.imageOfCardCloseIcon( - closeButtonBackground: style.closeButtonBackgroundColor, - closeButtonCross: style.closeButtonCrossColor - ) - dismissButton.setImage(closeButtonImage, for: .normal) - dismissButton.setTitle(nil, for: .normal) + TGCard.configureCloseButton(dismissButton, style: style) } } diff --git a/Sources/TGCardViewController/views/TGCardDefaultTitleView.xib b/Sources/TGCardViewController/views/TGCardDefaultTitleView.xib index 178086c..2dd4b4e 100644 --- a/Sources/TGCardViewController/views/TGCardDefaultTitleView.xib +++ b/Sources/TGCardViewController/views/TGCardDefaultTitleView.xib @@ -1,9 +1,9 @@ - + - + @@ -17,19 +17,19 @@ - + - + - + - + @@ -76,11 +76,14 @@ + + +