From f99d7da2bb71ed8d0427ec9674db546ff078450e Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Wed, 1 Sep 2021 13:03:46 +1000 Subject: [PATCH] Safe area for the map view (#2) Restructured TGCardVC slightly to have the map view in its own VC with, critically, its own safe area. The safe area is the visible part of the map when the card VC is in collapsed or peaking mode. This fixes the positioning of the attribution and legal label when used with MKMapView, and it fixes setting the centre location of the map or taking the map into tracking mode when the card is in peaking mode. Additional changes: - When the card is extended the map is made non-interactive to disable it from surfacing any VoiceOver from underneath the extended card. - Removes the need for manually handling edgePadding when using the MKMapView-based TGMapManager. --- Example/cards/ExampleMapManager.swift | 4 +- Example/cards/ExampleRootCard.swift | 2 +- .../TGCardViewController.swift | 175 +++++++++++------- .../TGCardViewController.xib | 61 +++--- .../TGCardViewController/TGMapManager.swift | 85 ++------- .../TGMapViewController.swift | 36 ++++ .../project.pbxproj | 4 + 7 files changed, 197 insertions(+), 170 deletions(-) create mode 100644 Sources/TGCardViewController/TGMapViewController.swift diff --git a/Example/cards/ExampleMapManager.swift b/Example/cards/ExampleMapManager.swift index 7b5c221..d35e3b1 100644 --- a/Example/cards/ExampleMapManager.swift +++ b/Example/cards/ExampleMapManager.swift @@ -58,10 +58,10 @@ class ExampleMapManager: TGMapManager, NSCoding { aCoder.encode(annotations.map(CodingAnnotation.init), forKey: "annotations") } - override func takeCharge(of mapView: MKMapView, edgePadding: UIEdgeInsets, animated: Bool) { + override func takeCharge(of mapView: MKMapView, animated: Bool) { mapView.delegate = self - super.takeCharge(of: mapView, edgePadding: edgePadding, animated: animated) + super.takeCharge(of: mapView, animated: animated) } func mapView(_ mapView: MKMapView, didChange mode: MKUserTrackingMode, animated: Bool) { diff --git a/Example/cards/ExampleRootCard.swift b/Example/cards/ExampleRootCard.swift index 929bae7..d0f8d6a 100644 --- a/Example/cards/ExampleRootCard.swift +++ b/Example/cards/ExampleRootCard.swift @@ -22,7 +22,7 @@ class ExampleRootCard : TGTableCard { #else title = .default("Card Demo") #endif - super.init(title: title, dataSource: source, delegate: source, mapManager: TGMapManager.nuremberg) + super.init(title: title, dataSource: source, delegate: source, mapManager: TGMapManager.nuremberg, initialPosition: .collapsed) source.onSelect = { item in self.controller?.push(item.card) diff --git a/Sources/TGCardViewController/TGCardViewController.swift b/Sources/TGCardViewController/TGCardViewController.swift index 2935ef3..77271ff 100644 --- a/Sources/TGCardViewController/TGCardViewController.swift +++ b/Sources/TGCardViewController/TGCardViewController.swift @@ -147,7 +147,6 @@ open class TGCardViewController: UIViewController { @IBOutlet weak var headerView: UIView! @IBOutlet weak var mapViewWrapper: UIView! - public weak var mapView: UIView! @IBOutlet weak var mapShadow: UIView! @IBOutlet weak var cardWrapperShadow: UIView! @IBOutlet public weak var cardWrapperContent: UIView! @@ -185,6 +184,9 @@ open class TGCardViewController: UIViewController { @IBOutlet weak var statusBarBlurHeightConstraint: NSLayoutConstraint! @IBOutlet weak var topInfoViewWrapperCenterXConstraint: NSLayoutConstraint! + var mapViewController = TGMapViewController() + public var mapView: UIView! { mapViewController.mapView } + var panner: UIPanGestureRecognizer! var cardTapper: UITapGestureRecognizer! var mapShadowTapper: UITapGestureRecognizer! @@ -196,7 +198,10 @@ open class TGCardViewController: UIViewController { /// `takeCharge(of:edgePadding:animated:)` and `cleanUp(_:animated:)` calls. /// /// @default: An instance of `TGMapKitBuilder`, i.e., using Apple's MapKit. - public var builder: TGCompatibleMapBuilder = TGMapKitBuilder() + public var builder: TGCompatibleMapBuilder { + get { mapViewController.builder } + set { mapViewController.builder = newValue } + } /// The card to display at the root. If you have more than one, use `initialCards` public var rootCard: TGCard? { @@ -290,15 +295,16 @@ open class TGCardViewController: UIViewController { sidebarSeparator.backgroundColor = UIColor(white: 1.0, alpha: 0.85) } - let mapView = builder.buildMapView() + addChild(mapViewController) + let mapView: UIView! = mapViewController.view mapViewWrapper.addSubview(mapView) mapView.topAnchor.constraint(equalTo: mapViewWrapper.topAnchor).isActive = true mapView.trailingAnchor.constraint(equalTo: mapViewWrapper.trailingAnchor).isActive = true mapView.bottomAnchor.constraint(equalTo: mapViewWrapper.bottomAnchor).isActive = true mapView.leadingAnchor.constraint(equalTo: mapViewWrapper.leadingAnchor).isActive = true mapView.translatesAutoresizingMaskIntoConstraints = false - self.mapView = mapView - + mapViewController.didMove(toParent: self) + setupGestures() // Create the default buttons @@ -322,7 +328,7 @@ open class TGCardViewController: UIViewController { hideInfoView(animated: false) // Collapse card at first - cardWrapperDesiredTopConstraint.constant = collapsedMinY + mapViewController.additionalSafeAreaInsets = updateCardPosition(y: collapsedMinY) // Add a bit of a shadow behind card. if mode == .floating { @@ -397,11 +403,11 @@ open class TGCardViewController: UIViewController { case (_, .peaking): distanceFromHeaderView = peakY case (_, .extended): distanceFromHeaderView = extendedMinY } - cardWrapperDesiredTopConstraint.constant = distanceFromHeaderView + mapViewController.additionalSafeAreaInsets = updateCardPosition(y: distanceFromHeaderView) } // Now is the time to restore - if let position = restoredCardPosition { + if let position = restoredCardPosition, didAddInitialCards { moveCard(to: position, animated: false) // During the state restoration process, cards are pushed and @@ -467,7 +473,8 @@ open class TGCardViewController: UIViewController { // 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. - cardWrapperDesiredTopConstraint.constant = cardLocation(forDesired: previous, direction: .up).y + let location = cardLocation(forDesired: previous, direction: .up) + mapViewController.additionalSafeAreaInsets = updateCardPosition(y: location.y) } // The position of a card's header also depends on size classes @@ -497,8 +504,8 @@ open class TGCardViewController: UIViewController { if view.superview != nil, !mapView.frame.isEmpty { if !didAddInitialCards { - initialCards.forEach { push($0, animated: false) } didAddInitialCards = true + initialCards.forEach { push($0, animated: false) } } fixPositioning() @@ -521,10 +528,6 @@ open class TGCardViewController: UIViewController { } } - override open func didReceiveMemoryWarning() { - super.didReceiveMemoryWarning() - // Dispose of any resources that can be recreated. - } // MARK: - UIStateRestoring @@ -660,7 +663,6 @@ open class TGCardViewController: UIViewController { /// map area to work with). fileprivate 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 @@ -689,7 +691,6 @@ open class TGCardViewController: UIViewController { bottom = height - cardY } - return UIEdgeInsets(top: top, left: left, bottom: bottom, right: 0) } @@ -831,7 +832,7 @@ extension TGCardViewController { // 1. Determine where the new card will go let forceExtended = (top.mapManager == nil) - let animateTo = cardLocation(forDesired: forceExtended ? .extended : top.initialPosition, direction: .down) + let animateTo = cardLocation(forDesired: forceExtended ? .extended : restoredCardPosition ?? top.initialPosition, direction: .down) // 2. Updating card logic and informing of transition let oldTop = cardWithView(atIndex: cards.count - 1) @@ -892,15 +893,6 @@ extension TGCardViewController { updatePannerInteractivity() updateGrabHandleVisibility() - // 6. Set new position of the wrapper - cardWrapperDesiredTopConstraint.constant = animateTo.y - if let cardView = cardView { - cardWrapperMinOverlapTopConstraint.constant = max( - Constants.minCardHeightWhenCollapsed, - cardView.headerHeight(for: .collapsed) - ) - } - let header = top.buildHeaderView() if let header = header { // Keep this to restore it later when hiding the header @@ -916,6 +908,13 @@ extension TGCardViewController { hideHeader(animated: animated) } + // Incoming card has its own top and bottom floating views. + updateFloatingViewsContent() + + // 6. Set new position of the wrapper (which is relative to the header) + updateCardStructure(card: cardView, position: .collapsed) + mapViewController.additionalSafeAreaInsets = updateCardPosition(y: animateTo.y) + // Notify that we have completed building the card view and its header view. top.cardView = cardView top.didBuild(cardView: cardView, headerView: header) @@ -931,9 +930,6 @@ extension TGCardViewController { } top.delegate = self - // Incoming card has its own top and bottom floating views. - updateFloatingViewsContent() - // Since the header view may be animated in & out, it's best to update the // height of the card's content wrapper. updateContentWrapperHeightConstraint() @@ -990,7 +986,7 @@ extension TGCardViewController { top.didMove(to: animateTo.position, animated: animated) } self.cardTransitionShadow?.removeFromSuperview() - self.updateCardHandleAccessibility(for: animateTo.position) + self.updateForNewPosition(position: animateTo.position) self.updateResponderChainForNewTopCard() self.toggleCardWrappers(hide: cardView == nil) completionHandler?() @@ -1059,36 +1055,29 @@ extension TGCardViewController { updatePannerInteractivity(for: newTop) updateGrabHandleVisibility(for: newTop) - // 4. Determine and set new position of the card wrapper + // TODO: It'd be better if we didn't have to build the header again, but could + // just re-use it from the previous push. + // See https://gitlab.com/SkedGo/tripgo-cards-ios/issues/7. + if let header = newTop?.card.buildHeaderView() { + showHeader(content: header, animated: animated) + } else if isShowingHeader { + hideHeader(animated: animated) + } + + // 4. Determine and set new position of the card wrapper (relative to header!) newTop?.view?.alpha = 1 // We only animate to the previous position if the card obscures the map + updateCardStructure(card: newTop?.view, position: newTop?.lastPosition) let animateTo: TGCardPosition let forceExtended = newTop?.card.mapManager == nil if forceExtended || !cardIsNextToMap(in: traitCollection) { let target = cardLocation(forDesired: forceExtended ? .extended : newTop?.lastPosition, direction: .down) - cardWrapperDesiredTopConstraint.constant = target.y animateTo = target.position + mapViewController.additionalSafeAreaInsets = updateCardPosition(y: target.y) } else { animateTo = cardPosition } - if let new = newTop, let newView = new.view { - cardWrapperMinOverlapTopConstraint.constant = max( - Constants.minCardHeightWhenCollapsed, - newView.headerHeight(for: new.lastPosition) - ) - } else { - cardWrapperMinOverlapTopConstraint.constant = 0 - } - - // TODO: It'd be better if we didn't have to build the header again, but could - // just re-use it from the previous push. - // See https://gitlab.com/SkedGo/tripgo-cards-ios/issues/7. - if let header = newTop?.card.buildHeaderView() { - showHeader(content: header, animated: animated) - } else if isShowingHeader { - hideHeader(animated: animated) - } // Since the header may be animated in and out, it's safer to update the height // of the card's content wrapper. @@ -1153,7 +1142,7 @@ extension TGCardViewController { topView?.removeFromSuperview() topView?.alpha = 1 self.cardTransitionShadow?.removeFromSuperview() - self.updateCardHandleAccessibility(for: animateTo) + self.updateForNewPosition(position: animateTo) self.updateResponderChainForNewTopCard() self.isPopping = false self.toggleCardWrappers(hide: newTop?.view == nil) @@ -1230,6 +1219,66 @@ 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 { + // The constraint moves the card into place + cardWrapperDesiredTopConstraint.constant = y + + // Adjusting the safe area moves the map's attribution and adjust how it + // centres the current location, etc. + var mapInsets = UIEdgeInsets.zero + if cardIsNextToMap(in: traitCollection) { + mapInsets.left = traitCollection.horizontalSizeClass == .regular ? 360 : view.frame.width * 0.38 // same as in storyboard + } else { + // Our view has the full height, including what's below safe area insets. + // The maximum interactive area is that without the bottom and anything + // covered by the optional header at the top. + let maxHeight = view.bounds.height - view.safeAreaInsets.bottom - headerView.frame.maxY + mapInsets.bottom = min( + maxHeight - peakY, // Don't collapse more than peaking. Instead we + // disable the map when extended in a related code + // path. + max( + maxHeight - y, // The usual case while dragging. + cardWrapperMinOverlapTopConstraint.constant // Don't extend beyond + // what the card covers when it's collapsed. + ) + ) + mapInsets.top = headerView.frame.maxY + } + if !bottomFloatingView.arrangedSubviews.isEmpty { + mapInsets.right = bottomFloatingViewWrapper.bounds.width + } + return mapInsets + } + + private func updateCardStructure(card: TGCardView?, position: TGCardPosition?) { + // Adjusting the safe area moves the map's attribution and adjust how it + // centres the current location, etc. + if let cardView = card { + let bottomOverlap = max( + Constants.minCardHeightWhenCollapsed, + cardView.headerHeight(for: position ?? .collapsed) + ) + cardWrapperMinOverlapTopConstraint.constant = bottomOverlap + } else { + cardWrapperMinOverlapTopConstraint.constant = 0 + } + } + + private func updateForNewPosition(position: TGCardPosition) { + previousCardPosition = position + + topCardView?.grabHandles.forEach { + updateCardHandleAccessibility(handle: $0, position: position) + } + + let mapIsInteractive = cardIsNextToMap(in: traitCollection) || position != .extended + mapViewController.isUserInteractionEnabled = mapIsInteractive + topFloatingViewWrapper.isUserInteractionEnabled = mapIsInteractive + bottomFloatingViewWrapper.isUserInteractionEnabled = mapIsInteractive + } + /// Determines where to snap the card wrapper to, considering its current /// location and the provided velocity. /// @@ -1301,7 +1350,7 @@ extension TGCardViewController { // animates nicely and not too suddenly. duration = min(max(duration, Constants.snapAnimationMinimumDuration), 1.3) - cardWrapperDesiredTopConstraint.constant = snapTo.y + let mapInset = updateCardPosition(y: snapTo.y) view.setNeedsUpdateConstraints() UIView.animate( @@ -1315,12 +1364,12 @@ extension TGCardViewController { self.topCardView?.adjustContentAlpha(to: snapTo.position == .collapsed ? 0 : 1) self.updateFloatingViewsVisibility(for: snapTo.position) self.view.layoutIfNeeded() + self.mapViewController.additionalSafeAreaInsets = mapInset }, completion: { _ in self.topCard?.mapManager?.edgePadding = self.mapEdgePadding(for: snapTo.position) self.topCard?.didMove(to: snapTo.position, animated: true) self.updateCardScrolling(allow: snapTo.position == .extended, view: self.topCardView) - self.previousCardPosition = snapTo.position - self.updateCardHandleAccessibility(for: snapTo.position) + self.updateForNewPosition(position: snapTo.position) completion?() }) } @@ -1374,7 +1423,7 @@ extension TGCardViewController { let newY = currentCardY + translation.y if (newY >= extendedMinY) && (newY <= collapsedMinY) { recogniser.setTranslation(.zero, in: cardWrapperContent) - cardWrapperDesiredTopConstraint.constant = newY + mapViewController.additionalSafeAreaInsets = updateCardPosition(y: newY) // Set alpha according to scrolling state, for a smooth transition // Collapsed: 0, peakY: 1 @@ -1458,7 +1507,7 @@ extension TGCardViewController { // This is where the magic happens: We move the card down and make // the scroll view appear to stay in place (it's important to not // set the content offset to zero here!) - cardWrapperDesiredTopConstraint.constant = extendedMinY - negativity + self.mapViewController.additionalSafeAreaInsets = updateCardPosition(y: extendedMinY - negativity) scrollView.transform = CGAffineTransform(translationX: 0, y: negativity) scrollView.verticalScrollIndicatorInsets.top = negativity * -1 @@ -1496,7 +1545,7 @@ extension TGCardViewController { let animateTo = cardLocation(forDesired: position, direction: direction) - cardWrapperDesiredTopConstraint.constant = animateTo.y + let mapInsets = updateCardPosition(y: animateTo.y) view.setNeedsUpdateConstraints() UIView.animate( @@ -1508,13 +1557,13 @@ extension TGCardViewController { self.topCardView?.adjustContentAlpha(to: animateTo.position == .collapsed ? 0 : 1) self.updateFloatingViewsVisibility(for: animateTo.position) self.view.layoutIfNeeded() + self.mapViewController.additionalSafeAreaInsets = mapInsets }, completion: { _ in self.topCard?.mapManager?.edgePadding = self.mapEdgePadding(for: animateTo.position) self.topCard?.didMove(to: animateTo.position, animated: animated) self.updateCardScrolling(allow: animateTo.position == .extended, view: self.topCardView) - self.previousCardPosition = animateTo.position - self.updateCardHandleAccessibility(for: animateTo.position) + self.updateForNewPosition(position: animateTo.position) handler?() }) } @@ -1617,7 +1666,7 @@ extension TGCardViewController { } } - private func deviceIsiPhoneX() -> Bool { view.safeAreaInsets.bottom > 0 } + private func deviceIsiPhoneX() -> Bool { view.window?.safeAreaInsets.bottom ?? 0 > 0 } private func fadeMapFloatingViews(_ fade: Bool, animated: Bool) { UIView.animate(withDuration: animated ? 0.25: 0) { @@ -2117,15 +2166,11 @@ extension TGCardViewController { return true } - private func updateCardHandleAccessibility(for position: TGCardPosition? = nil) { - topCardView?.grabHandles.forEach { updateCardHandleAccessibility(handle: $0, position: position) } - } - - private func updateCardHandleAccessibility(handle: TGGrabHandleView, position: TGCardPosition?) { + private func updateCardHandleAccessibility(handle: TGGrabHandleView, position: TGCardPosition) { handle.isAccessibilityElement = true handle.accessibilityCustomActions = buildCardHandleAccessibilityActions() - switch position ?? cardPosition { + switch position { case .collapsed: handle.accessibilityLabel = NSLocalizedString( "Card controller minimised", bundle: .cardVC, diff --git a/Sources/TGCardViewController/TGCardViewController.xib b/Sources/TGCardViewController/TGCardViewController.xib index dd40ce4..391b53e 100644 --- a/Sources/TGCardViewController/TGCardViewController.xib +++ b/Sources/TGCardViewController/TGCardViewController.xib @@ -1,9 +1,9 @@ - - + + - + @@ -44,29 +44,29 @@ - + - + - + - + - + - +