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 @@ - + - + - + - + - + - +