Handling scroll views with (custom, interactive) view controller presentation and dismissal
Asked Answered
E

3

17

I have been experimenting with custom interactive view controller presentation and dismissal (using a combination of UIPresentationController, UIPercentDrivenInteractiveTransition, UIViewControllerAnimatedTransitioning, and UIViewControllerTransitioningDelegate) and have mostly gotten things working well for my needs.

However, there is one common scenario that I've yet to find addressed in any of the tutorials or documentation that I've read, leading me to the following question:

...

What is the proper way of handling custom interactive view controller dismissal, via a pan gesture, when the dismissed view contains a UIScrollView (ie. UITableView, UICollectionView, WKWebView, etc)?

...

Basically, what I'd like is for the following:

  1. View controllers are interactively dismissible by panning them down. This is common UX in many apps.

  2. If the dismissed view controller contains a (vertically-scrolling) scroll view, panning down scrolls that view as expected until the user reaches the top, after which the scrolling ceases and the pan-to-dismiss occurs.

  3. Scroll views should otherwise behave as normal.

I know that this is technically possible - I've seen it in other apps, such as Overcast and Apple's own Music app - but I've not been able to find the key to coordinating the behavior of my pan gesture with that of the scroll view(s).

Most of my own attempts center on trying to conditionally enable/disable the scrollview (or its associated pan gesture recognizer) based on its contentOffset.y while scrolling and having the view controller dismissal's pan gesture recognizer take over from there, but this has been fraught with problems and I fear that I am overthinking it.

I feel like the secret mostly lies in the following pan gesture recognizer delegate method:

func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
   // ...
}

I have created a reduced sample project which should demonstrate the scenario more clearly. Any code suggestions are highly welcome!

https://github.com/Darchmare/SlidePanel-iOS

Encratia answered 24/4, 2018 at 22:41 Comment(0)
C
26

Solution

  • Make scrollView stop scrolling after it reached top by using UIScrollView's bounces property and scrollViewDidScroll(_:) method.

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        scrollView.bounces = (scrollView.contentOffset.y > 10);
    }
    

    Don't forget to set scrollView.delegate = self

  • Only handle panGestureRecognizer when scrollView reached top - It means when scrollView.contentOffset.y == 0 by using a protocol.

    protocol PanelAnimationControllerDelegate {
        func shouldHandlePanelInteractionGesture() -> Bool
    }
    

    ViewController

    func shouldHandlePanelInteractionGesture() -> Bool {
        return (scrollView.contentOffset.y == 0);
    }
    

    PanelInteractionController

    class PanelInteractionController: ... {
    
      var startY:CGFloat = 0
    
      private weak var viewController: (UIViewController & PanelAnimationControllerDelegate)?
    
      @objc func handlePanGestureRecognizer(_ gestureRecognizer: UIPanGestureRecognizer) {
        switch gestureRecognizer.state {
        case .began:
          break
        case .changed:
          let translation    = gestureRecognizer.translation(in: gestureRecognizer.view!.superview!)
          let velocity    = gestureRecognizer.velocity(in: gestureRecognizer.view!.superview)
          let state      = gestureRecognizer.state
    
          // Don't do anything when |scrollView| is scrolling
          if !(viewController?.shouldHandlePanelInteractionGesture())! && percentComplete == 0 {
            return;
          }
    
          var rawProgress    = CGFloat(0.0)
    
          rawProgress    = ((translation.y - startTransitionY) / gestureRecognizer.view!.bounds.size.height)
    
          let progress    = CGFloat(fminf(fmaxf(Float(rawProgress), 0.0), 1.0))
    
          if abs(velocity.x) > abs(velocity.y) && state == .began {
            // If the user attempts a pan and it looks like it's going to be mostly horizontal, bail - we don't want it... - JAC
            return
          }
    
          if !self.interactionInProgress {
            // Start to pan |viewController| down
            self.interactionInProgress = true
            startTransitionY = translation.y;
            self.viewController?.dismiss(animated: true, completion: nil)
          } else {
            // If the user gets to a certain point within the dismissal and releases the panel, allow the dismissal to complete... - JAC
            self.shouldCompleteTransition = progress > 0.2
    
            update(progress)
          }
        case .cancelled:
          self.interactionInProgress = false
          startTransitionY = 0
    
          cancel()
        case .ended:
          self.interactionInProgress = false
          startTransitionY = 0
    
          if self.shouldCompleteTransition == false {
            cancel()
          } else {
            finish()
          }
        case .failed:
          self.interactionInProgress = false
          startTransitionY = 0
    
          cancel()
        default:
          break;
        }
      }
    }
    

Result

enter link description here

For more detail, you can take a look at my sample project

Counterstroke answered 27/4, 2018 at 10:10 Comment(3)
Thats very clean approach to handle scrollView with panGesture.Lustrous
So clean and understandable :)Situated
work as a charmTurbellarian
C
1

For me, this little bit of code answered a lot of my issues and greatly helped my custom transitions in scrollviews, it will hold a negative scrollview offset from moving while trying to start a transition or showing an activity indicator on the top. My guess is that this will solve at least some of your transition/animation hiccups:

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {

    if scrollView.contentOffset.y < -75 {

        scrollView.contentInset.top = -scrollView.contentOffset.y

    }
    // Do animation or transition
}
Culicid answered 29/4, 2018 at 20:37 Comment(0)
B
0

I believe you don't need an additional pan gesture recognizer to implement this. You can simply hook onto the different delegate methods of the scroll view to achieve the "pan to dismiss" effect. Here is how I went about it

// Set the dragging property to true
func scrollViewWillBeginDragging(_: UIScrollView) {
    isDragging = true
}

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    // If not dragging, we could make an early exit
    guard isDragging else {
        return
    }

    let topOffset = scrollView.contentOffset.y + statusBarHeight

// If The dismissal has not already started and the user has scrolled to the top and they are currently scrolling, then initiate the interactive dismissal
    if !isDismissing && topOffset <= 0 && scrollView.isTracking {
        startInteractiveTransition()
        return
    }

    // If its already being dismissed, then calculate the progress and update the interactive dismissal animator 
    if isDismissing {
        updateInteractiveTransitionProgress()
    }
}

// Once the scroll ends, check for a few things
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
    // Early return   
    if !isDismissing {
        return
    }
    
    // Optional check to dismiss the controller, if swiped from the top
    checkForFastSwipes()
    
    // If dragged enough, dismiss the controller, otherwise cancel the 
       transition 
    if interactor?.shouldFinish ?? false {
        interactor?.finish()
    } else {
        interactor?.cancel()
    }
     
    // Finally reset the transition properties
    resetTransitionProperties()
}

private func checkForFastSwipes() {
    let velocity = scrollView.panGestureRecognizer.velocity(in: view)
    let velDiff = velocity.y - velocity.x
    if velDiff > 0 && velDiff >= 75 {
        interactor?.hasStarted = false
        self.dismiss()
        interactor?.shouldFinish = true
    }
}

private func startInteractiveTransition() {
    isDismissing = true
    interactor?.hasStarted = true
    dismiss()
}

private func updateInteractiveTransitionProgress() {
    progress = max(
        0.0,
        min(1.0, ((-scrollView.contentOffset.y) - statusBarHeight) / 90.0)
    )
    interactor?.shouldFinish = progress > 0.5
    interactor?.update(progress)
}

private func resetTransitionProperties() {
    isDismissing = false
    isDragging = false
}

The interactor property used for synchronizing the animation with gesture

var interactor: Interactor?
class Interactor: UIPercentDrivenInteractiveTransition {
    var hasStarted = false
    var shouldFinish = false
}

Inspired by the following Kodeco tutorial
https://www.kodeco.com/books/ios-animations-by-tutorials/v6.0/chapters/25-uiviewpropertyanimator-view-controller-transitions
(Look for the Interactive view controller transitions section)

Edit

After implementing the solution below, I realized that it only works if you have sufficient content to scroll, however, if you have dynamic content wherein the contents are not guaranteed to be scrollable as was my case, you'd be better off adding a pan gesture as mentioned by @trungduc. However, there are a few improvements that we could make to their answer like detecting an upwards scroll and not letting it interfere with our gesture.

Under the changed state add the following code

let isUpwardsScroll = self.velocity(in: target).y < 0  
/* 
 If the user is normally scrolling the view, ignore it. However, 
 once the interaction starts allow such gestures as they could be  
 dragging the interactable view back
*/
if isUpwardScroll && !interactor.hasStarted {
   return
}
Behindhand answered 3/11, 2022 at 21:29 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.