Handling scroll views with (custom, interactive) view controller presentation and dismissal - ios

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:
View controllers are interactively dismissible by panning them down. This is common UX in many apps.
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.
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

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
For more detail, you can take a look at my sample project

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
}

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
}

Related

Manually starting a UIPanGestureRecognizer without touch

I have a table view that doesn't cover the whole screen (It's kind of like a drawer from the bottom of the screen). When the user scrolls down to the end of the content I want to stop the scrolling and then add a pan gesture recognizer. I do this like so:
// MARK: UIScrollViewDelegate Methods
extension TutorProfileVC: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// Limit top vert bounce
guard mode == .drawer else { return }
if scrollView.contentOffset.y < -80.0 {
scrollView.contentOffset = CGPoint(x: 0, y: -80.0)
tableView.addGestureRecognizer(tablePanGR)
}
}
}
The gesture has been added but won't register until the user touches the screen again. Their finger is already on the tableview. Is it possible to start the gesture without them having to touch the screen again?
I think you have same problem with this question. Take a look at it if you want to see a code sample.
To resolve problem, you should add gesture from beginning but only handle gesture action when user scrolls to bottom. So you don't need to touch the screen again because gesture is started when you begin scrolling. The method to handle gesture will look like below
#objc func handlePanGestureRecognizer(_ gestureRecognizer: UIPanGestureRecognizer) {
switch gestureRecognizer.state {
case .began:
// Do nothing
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 until |scrollView| reached bottom
if scrollView.contentOffset.y >= -80.0 {
return;
}
// Do whatever you want with |scrollView|
}
break;
case .cancelled:
case .ended:
case .failed:
default:
break;
}
}
Also implement gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer: to make gesture and scroll view work together
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}

After running a view controller custom transition back and forward the view controller stops responding (but the app doesn't crash entirely)

I have two view controllers and from the first one I show the second one by a modal segue, which presentation style is set to over current context. I also use blur effect which appears during the transition and disappears after it.
I've created a demo app to show how the transition looks like:
Here, the second view controller contains a UIScrollView, and on top of that, a yellow rectangle, which is another UIView with a UIButton on it. That UIButton also closes the view controller. Also, as you can see, I've set the background color to clear, so the blur effect is visible.
Now, in my project the transition is basically the same, except, my view controllers are "heavier".
The first view controller is embedded inside a UINavigationController" and a UITabBarController and on it I have a custom segmented control which
is made from UIViews and UIButtons, and a UITableView with custom cells.
The second view controller consists of a UIScrollView and a UIView (like on the image above, just bigger a little). That view contains a UIImageView, UILabels and a smaller UIView which is used as button to close the view by tapping on it.
Here is the code that I use to close the second view controller by dragging it down (UIScrollViewDelegate's methods).
extension AuthorInfoViewController: UIScrollViewDelegate {
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
isDragging = true
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard isDragging else {
return
}
if !isDismissingAuthorInfo && scrollView.contentOffset.y < -30.0 && dismissAnimator != nil {
authoInfoViewControllerDelegate?.authorInfoViewControllerWillDismiss()
isDismissingAuthorInfo = true
dismissAnimator?.wantsInteractiveStart = true
dismiss(animated: true, completion: nil)
return
}
if isDismissingAuthorInfo {
let progress = max(0.0, min(1.0, ((-scrollView.contentOffset.y) - 30) / 90.0))
dismissAnimator?.update(progress)
}
}
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
let progress = max(0.0, min(1.0, ((-scrollView.contentOffset.y) - 30) / 90.0))
if progress > 0.5 {
dismissAnimator?.finish()
} else {
dismissAnimator?.cancel()
print("Canceled")
}
isDismissingAuthorInfo = false
isDragging = false
}
}
Here, isDismissingAuthorInfo and isDragging are booleans which keep
track whether the view is dismissing and is dragged at all. authorInfoViewControllerWillDismiss is a method implemented in a protocol to which conforms the first view controller. That methods calls another method which adds the blur animation to the custom transitions animator.
EDITED
The animator code is the following:
class DismissAnimator: UIPercentDrivenInteractiveTransition, UIViewControllerAnimatedTransitioning {
var auxAnimationsForBlur: (()->Void)?
var auxAnimationsForTabBar: (()->Void)?
var auxAnimationsCancelForBlur: (()->Void)?
var auxAnimationsCancelForTabBar: (()->Void)?
var tabBar: UITabBar?
var blurView: UIView?
let transitionDuration = 0.75
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return transitionDuration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
transitionAnimator(using: transitionContext).startAnimation()
}
func transitionAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
let duration = transitionDuration(using: transitionContext)
let container = transitionContext.containerView
let from = transitionContext.view(forKey: .from)!
container.addSubview(from)
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeOut)
animator.addAnimations({
from.transform = CGAffineTransform(translationX: 0.0, y: container.frame.size.height + 30)
}, delayFactor: 0.15)
animator.addCompletion { (position) in
switch position {
case .end:
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
self.tabBar?.isHidden = false
self.blurView?.removeFromSuperview()
default:
transitionContext.completeTransition(false)
self.auxAnimationsCancelForBlur?()
self.auxAnimationsCancelForTabBar?()
}
}
if let auxAnimationsForBlur = auxAnimationsForBlur {
animator.addAnimations(auxAnimationsForBlur)
}
if let auxAnimationsForTabBar = auxAnimationsForTabBar {
animator.addAnimations(auxAnimationsForTabBar)
}
return animator
}
func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
return transitionAnimator(using:transitionContext)
}
}
In the code above, the auxAnimationsForBlur is for adding blur animation to the animator, the auxAnimationsCancelForBlur is for canceling it, auxAnimationsForTabBar is for adding the animation of alpha value of tabBar, auxAnimationsCancelForTabBar is for canceling it.
Now, the problem is the following: the animation works fine, but after running it by dragging the second view controller for several times (5-9, approximately), after the transition is over and the first view controller is shown, it just stops responding. However, on the bottom I have a tab bar, and it works. So, when I change to another tab and return back, I see the second view controller and a black screen behind it (where the first view controller should have been). When this happens the cancel method on UIPercentDrivenInteractiveTransition gets called, from scrollViewWillEndDragging method above (on the console I see the word cancel printed). Is it possible that cancel method on UIPercentDrivenInteractiveTransition doesn't causes this problem, because when I comment out the call to that method, it seems everything works ok (in that case, I also comment out the call to interruptibleAnimator(using:) method on my animator, so I lose the interactive behaviour of the transition)? I couldn't reproduce this behaviour while closing the second view controller by tapping on a close button, so I think it has something to do with dragging.
What could cause this problem, and what could you suggest for solving it? I would appreciate all your help.

How can I mimic the bottom sheet from the Maps app?

Can anyone tell me how I can mimic the bottom sheet in the new Apple Maps app in iOS 10?
In Android, you can use a BottomSheet which mimics this behaviour, but I could not find anything like that for iOS.
Is that a simple scroll view with a content inset, so that the search bar is at the bottom?
I am fairly new to iOS programming so if someone could help me creating this layout, that would be highly appreciated.
This is what I mean by "bottom sheet":
I don't know how exactly the bottom sheet of the new Maps app, responds to user interactions. But you can create a custom view that looks like the one in the screenshots and add it to the main view.
I assume you know how to:
1- create view controllers either by storyboards or using xib files.
2- use googleMaps or Apple's MapKit.
Example
1- Create 2 view controllers e.g, MapViewController and BottomSheetViewController. The first controller will host the map and the second is the bottom sheet itself.
Configure MapViewController
Create a method to add the bottom sheet view.
func addBottomSheetView() {
// 1- Init bottomSheetVC
let bottomSheetVC = BottomSheetViewController()
// 2- Add bottomSheetVC as a child view
self.addChildViewController(bottomSheetVC)
self.view.addSubview(bottomSheetVC.view)
bottomSheetVC.didMoveToParentViewController(self)
// 3- Adjust bottomSheet frame and initial position.
let height = view.frame.height
let width = view.frame.width
bottomSheetVC.view.frame = CGRectMake(0, self.view.frame.maxY, width, height)
}
And call it in viewDidAppear method:
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
addBottomSheetView()
}
Configure BottomSheetViewController
1) Prepare background
Create a method to add blur and vibrancy effects
func prepareBackgroundView(){
let blurEffect = UIBlurEffect.init(style: .Dark)
let visualEffect = UIVisualEffectView.init(effect: blurEffect)
let bluredView = UIVisualEffectView.init(effect: blurEffect)
bluredView.contentView.addSubview(visualEffect)
visualEffect.frame = UIScreen.mainScreen().bounds
bluredView.frame = UIScreen.mainScreen().bounds
view.insertSubview(bluredView, atIndex: 0)
}
call this method in your viewWillAppear
override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
prepareBackgroundView()
}
Make sure that your controller's view background color is clearColor.
2) Animate bottomSheet appearance
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
UIView.animateWithDuration(0.3) { [weak self] in
let frame = self?.view.frame
let yComponent = UIScreen.mainScreen().bounds.height - 200
self?.view.frame = CGRectMake(0, yComponent, frame!.width, frame!.height)
}
}
3) Modify your xib as you want.
4) Add Pan Gesture Recognizer to your view.
In your viewDidLoad method add UIPanGestureRecognizer.
override func viewDidLoad() {
super.viewDidLoad()
let gesture = UIPanGestureRecognizer.init(target: self, action: #selector(BottomSheetViewController.panGesture))
view.addGestureRecognizer(gesture)
}
And implement your gesture behaviour:
func panGesture(recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translationInView(self.view)
let y = self.view.frame.minY
self.view.frame = CGRectMake(0, y + translation.y, view.frame.width, view.frame.height)
recognizer.setTranslation(CGPointZero, inView: self.view)
}
Scrollable Bottom Sheet:
If your custom view is a scroll view or any other view that inherits from, so you have two options:
First:
Design the view with a header view and add the panGesture to the header. (bad user experience).
Second:
1 - Add the panGesture to the bottom sheet view.
2 - Implement the UIGestureRecognizerDelegate and set the panGesture delegate to the controller.
3- Implement shouldRecognizeSimultaneouslyWith delegate function and disable the scrollView isScrollEnabled property in two case:
The view is partially visible.
The view is totally visible, the scrollView contentOffset property is 0 and the user is dragging the view downwards.
Otherwise enable scrolling.
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
let gesture = (gestureRecognizer as! UIPanGestureRecognizer)
let direction = gesture.velocity(in: view).y
let y = view.frame.minY
if (y == fullView && tableView.contentOffset.y == 0 && direction > 0) || (y == partialView) {
tableView.isScrollEnabled = false
} else {
tableView.isScrollEnabled = true
}
return false
}
NOTE
In case you set .allowUserInteraction as an animation option, like in the sample project, so you need to enable scrolling on the animation completion closure if the user is scrolling up.
Sample Project
I created a sample project with more options on this repo which may give you better insights about how to customise the flow.
In the demo, addBottomSheetView() function controls which view should be used as a bottom sheet.
Sample Project Screenshots
- Partial View
- FullView
- Scrollable View
Update iOS 15
In iOS 15, you can now use the native UISheetPresentationController.
if let sheet = viewControllerToPresent.sheetPresentationController {
sheet.detents = [.medium(), .large()]
// your sheet setup
}
present(viewControllerToPresent, animated: true, completion: nil)
Notice that you can even reproduce its navigation stack using the overcurrentcontext presentation mode:
let nextViewControllerToPresent: UIViewController = ...
nextViewControllerToPresent.modalPresentationStyle = .overCurrentContext
viewControllerToPresent.present(nextViewControllerToPresent, animated: true, completion: nil)
Legacy
I released a library based on my answer below.
It mimics the Shortcuts application overlay. See this article for details.
The main component of the library is the OverlayContainerViewController. It defines an area where a view controller can be dragged up and down, hiding or revealing the content underneath it.
let contentController = MapsViewController()
let overlayController = SearchViewController()
let containerController = OverlayContainerViewController()
containerController.delegate = self
containerController.viewControllers = [
contentController,
overlayController
]
window?.rootViewController = containerController
Implement OverlayContainerViewControllerDelegate to specify the number of notches wished:
enum OverlayNotch: Int, CaseIterable {
case minimum, medium, maximum
}
func numberOfNotches(in containerViewController: OverlayContainerViewController) -> Int {
return OverlayNotch.allCases.count
}
func overlayContainerViewController(_ containerViewController: OverlayContainerViewController,
heightForNotchAt index: Int,
availableSpace: CGFloat) -> CGFloat {
switch OverlayNotch.allCases[index] {
case .maximum:
return availableSpace * 3 / 4
case .medium:
return availableSpace / 2
case .minimum:
return availableSpace * 1 / 4
}
}
SwiftUI (12/29/20)
A SwiftUI version of the library is now available.
Color.red.dynamicOverlay(Color.green)
Previous answer
I think there is a significant point that is not treated in the suggested solutions: the transition between the scroll and the translation.
In Maps, as you may have noticed, when the tableView reaches contentOffset.y == 0, the bottom sheet either slides up or goes down.
The point is tricky because we can not simply enable/disable the scroll when our pan gesture begins the translation. It would stop the scroll until a new touch begins. This is the case in most of the proposed solutions here.
Here is my try to implement this motion.
Starting point: Maps App
To start our investigation, let's visualize the view hierarchy of Maps (start Maps on a simulator and select Debug > Attach to process by PID or Name > Maps in Xcode 9).
It doesn't tell how the motion works, but it helped me to understand the logic of it. You can play with the lldb and the view hierarchy debugger.
Our view controller stacks
Let's create a basic version of the Maps ViewController architecture.
We start with a BackgroundViewController (our map view):
class BackgroundViewController: UIViewController {
override func loadView() {
view = MKMapView()
}
}
We put the tableView in a dedicated UIViewController:
class OverlayViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
lazy var tableView = UITableView()
override func loadView() {
view = tableView
tableView.dataSource = self
tableView.delegate = self
}
[...]
}
Now, we need a VC to embed the overlay and manage its translation.
To simplify the problem, we consider that it can translate the overlay from one static point OverlayPosition.maximum to another OverlayPosition.minimum.
For now it only has one public method to animate the position change and it has a transparent view:
enum OverlayPosition {
case maximum, minimum
}
class OverlayContainerViewController: UIViewController {
let overlayViewController: OverlayViewController
var translatedViewHeightContraint = ...
override func loadView() {
view = UIView()
}
func moveOverlay(to position: OverlayPosition) {
[...]
}
}
Finally we need a ViewController to embed the all:
class StackViewController: UIViewController {
private var viewControllers: [UIViewController]
override func viewDidLoad() {
super.viewDidLoad()
viewControllers.forEach { gz_addChild($0, in: view) }
}
}
In our AppDelegate, our startup sequence looks like:
let overlay = OverlayViewController()
let containerViewController = OverlayContainerViewController(overlayViewController: overlay)
let backgroundViewController = BackgroundViewController()
window?.rootViewController = StackViewController(viewControllers: [backgroundViewController, containerViewController])
The difficulty behind the overlay translation
Now, how to translate our overlay?
Most of the proposed solutions use a dedicated pan gesture recognizer, but we actually already have one : the pan gesture of the table view.
Moreover, we need to keep the scroll and the translation synchronised and the UIScrollViewDelegate has all the events we need!
A naive implementation would use a second pan Gesture and try to reset the contentOffset of the table view when the translation occurs:
func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
if isTranslating {
tableView.contentOffset = .zero
}
}
But it does not work. The tableView updates its contentOffset when its own pan gesture recognizer action triggers or when its displayLink callback is called. There is no chance that our recognizer triggers right after those to successfully override the contentOffset.
Our only chance is either to take part of the layout phase (by overriding layoutSubviews of the scroll view calls at each frame of the scroll view) or to respond to the didScroll method of the delegate called each time the contentOffset is modified. Let's try this one.
The translation Implementation
We add a delegate to our OverlayVC to dispatch the scrollview's events to our translation handler, the OverlayContainerViewController :
protocol OverlayViewControllerDelegate: class {
func scrollViewDidScroll(_ scrollView: UIScrollView)
func scrollViewDidStopScrolling(_ scrollView: UIScrollView)
}
class OverlayViewController: UIViewController {
[...]
func scrollViewDidScroll(_ scrollView: UIScrollView) {
delegate?.scrollViewDidScroll(scrollView)
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
delegate?.scrollViewDidStopScrolling(scrollView)
}
}
In our container, we keep track of the translation using a enum:
enum OverlayInFlightPosition {
case minimum
case maximum
case progressing
}
The current position calculation looks like :
private var overlayInFlightPosition: OverlayInFlightPosition {
let height = translatedViewHeightContraint.constant
if height == maximumHeight {
return .maximum
} else if height == minimumHeight {
return .minimum
} else {
return .progressing
}
}
We need 3 methods to handle the translation:
The first one tells us if we need to start the translation.
private func shouldTranslateView(following scrollView: UIScrollView) -> Bool {
guard scrollView.isTracking else { return false }
let offset = scrollView.contentOffset.y
switch overlayInFlightPosition {
case .maximum:
return offset < 0
case .minimum:
return offset > 0
case .progressing:
return true
}
}
The second one performs the translation. It uses the translation(in:) method of the scrollView's pan gesture.
private func translateView(following scrollView: UIScrollView) {
scrollView.contentOffset = .zero
let translation = translatedViewTargetHeight - scrollView.panGestureRecognizer.translation(in: view).y
translatedViewHeightContraint.constant = max(
Constant.minimumHeight,
min(translation, Constant.maximumHeight)
)
}
The third one animates the end of the translation when the user releases its finger. We calculate the position using the velocity & the current position of the view.
private func animateTranslationEnd() {
let position: OverlayPosition = // ... calculation based on the current overlay position & velocity
moveOverlay(to: position)
}
Our overlay's delegate implementation simply looks like :
class OverlayContainerViewController: UIViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard shouldTranslateView(following: scrollView) else { return }
translateView(following: scrollView)
}
func scrollViewDidStopScrolling(_ scrollView: UIScrollView) {
// prevent scroll animation when the translation animation ends
scrollView.isEnabled = false
scrollView.isEnabled = true
animateTranslationEnd()
}
}
Final problem: dispatching the overlay container's touches
The translation is now pretty efficient. But there is still a final problem: the touches are not delivered to our background view. They are all intercepted by the overlay container's view.
We can not set isUserInteractionEnabled to false because it would also disable the interaction in our table view. The solution is the one used massively in the Maps app, PassThroughView:
class PassThroughView: UIView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let view = super.hitTest(point, with: event)
if view == self {
return nil
}
return view
}
}
It removes itself from the responder chain.
In OverlayContainerViewController:
override func loadView() {
view = PassThroughView()
}
Result
Here is the result:
You can find the code here.
Please if you see any bugs, let me know ! Note that your implementation can of course use a second pan gesture, specially if you add a header in your overlay.
Update 23/08/18
We can replace scrollViewDidEndDragging with
willEndScrollingWithVelocity rather than enabling/disabling the scroll when the user ends dragging:
func scrollView(_ scrollView: UIScrollView,
willEndScrollingWithVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>) {
switch overlayInFlightPosition {
case .maximum:
break
case .minimum, .progressing:
targetContentOffset.pointee = .zero
}
animateTranslationEnd(following: scrollView)
}
We can use a spring animation and allow user interaction while animating to make the motion flow better:
func moveOverlay(to position: OverlayPosition,
duration: TimeInterval,
velocity: CGPoint) {
overlayPosition = position
translatedViewHeightContraint.constant = translatedViewTargetHeight
UIView.animate(
withDuration: duration,
delay: 0,
usingSpringWithDamping: velocity.y == 0 ? 1 : 0.6,
initialSpringVelocity: abs(velocity.y),
options: [.allowUserInteraction],
animations: {
self.view.layoutIfNeeded()
}, completion: nil)
}
Try Pulley:
Pulley is an easy to use drawer library meant to imitate the drawer
in iOS 10's Maps app. It exposes a simple API that allows you to use
any UIViewController subclass as the drawer content or the primary
content.
https://github.com/52inc/Pulley
I wrote my own library to achieve the intended behaviour in ios Maps app. It is a protocol oriented solution. So you don't need to inherit any base class instead create a sheet controller and configure as you wish. It also supports inner navigation/presentation with or without UINavigationController.
See below link for more details.
https://github.com/OfTheWolf/UBottomSheet
You can try my answer https://github.com/SCENEE/FloatingPanel. It provides a container view controller to display a "bottom sheet" interface.
It's easy to use and you don't mind any gesture recognizer handling! Also you can track a scroll view's(or the sibling view) in a bottom sheet if needed.
This is a simple example. Please note that you need to prepare a view controller to display your content in a bottom sheet.
import UIKit
import FloatingPanel
class ViewController: UIViewController {
var fpc: FloatingPanelController!
override func viewDidLoad() {
super.viewDidLoad()
fpc = FloatingPanelController()
// Add "bottom sheet" in self.view.
fpc.add(toParent: self)
// Add a view controller to display your contents in "bottom sheet".
let contentVC = ContentViewController()
fpc.set(contentViewController: contentVC)
// Track a scroll view in "bottom sheet" content if needed.
fpc.track(scrollView: contentVC.tableView)
}
...
}
Here is another example code to display a bottom sheet to search a location like Apple Maps.
iOS 15 in 2021 adds UISheetPresentationController, which is Apple's first public release of an Apple Maps-style "bottom sheet":
UISheetPresentationController
UISheetPresentationController lets you present your view controller as a sheet. Before you present your view controller, configure its sheet presentation controller with the behavior and appearance you want for your sheet.
Sheet presentation controllers specify a sheet's size based on a detent, a height where a sheet naturally rests. Detents allow a sheet to resize from one edge of its fully expanded frame while the other three edges remain fixed. You specify the detents that a sheet supports using detents, and monitor its most recently selected detent using selectedDetentIdentifier.
https://developer.apple.com/documentation/uikit/uisheetpresentationcontroller
This new bottom sheet control is explored in WWDC Session 10063: Customize and Resize Sheets in UIKit
Unfortunately....
In iOS 15, the UISheetPresentationController has launched with only medium and large detents.
A small detent is notably absent from the iOS 15 API, which would be required to display an always-presented "collapsed" bottom sheet like Apple Maps:
Custom smaller Detents in UISheetPresentationController?
The medium detent was released to handle use cases such as the Share Sheet or the "••• More" menu in Mail: a button-triggered half sheet.
In iOS 15, Apple Maps is now using this UIKit sheet presentation rather than a custom implementation, which is a huge step in the right direction. Apple Maps in iOS 15 continues to show the "small" bar, as well as a 1/3rd height bar. But those view sizes are not public API available to developers.
UIKit engineers at the WWDC 2021 Labs seemed to know that a small detent would be a hugely popular UIKit component. I would expect to see an API expansion for iOS 16 next year.
We’ve just released a pure Swift Package supporting iOS 11.4+ which provides you a BottomSheet with theme and behavior options you can customize. This component is easy to use, and flexible. You can find it here: https://github.com/LunabeeStudio/LBBottomSheet.
A demo project is available in this repository too.
For example, it supports different ways to manage the needed height, and also adds to the controller behind it the ability to detect height changes and adapt its bottom content inset.
You can find more information on the GitHub repository and in the documentation: https://lbbottomsheet.lunabee.studio.
I think it can help you to do what you’re looking for. Don’t hesitate to tell me if you have comments/questions :)
Here you can see one of all the possible BottomSheet configurations:
**for iOS 15 Native Support available for this **
#IBAction func openSheet() {
let secondVC = self.storyboard?.instantiateViewController(withIdentifier: "SecondViewController")
// Create the view controller.
if #available(iOS 15.0, *) {
let formNC = UINavigationController(rootViewController: secondVC!)
formNC.modalPresentationStyle = UIModalPresentationStyle.pageSheet
guard let sheetPresentationController = formNC.presentationController as? UISheetPresentationController else {
return
}
sheetPresentationController.detents = [.medium(), .large()]
sheetPresentationController.prefersGrabberVisible = true
present(formNC, animated: true, completion: nil)
} else {
// Fallback on earlier versions
}
}
iOS 15 finally adds a native UISheetPresentationController!
Official documentation
https://developer.apple.com/documentation/uikit/uisheetpresentationcontroller
I recently created a component called SwipeableView as subclass of UIView, written in Swift 5.1 . It support all 4 direction, has several customisation options and can animate and interpolate different attributes and items ( such as layout constraints, background/tint color, affine transform, alpha channel and view center, all of them demoed with the respective show case ). It also supports the swiping coordination with the inner scroll view if set or auto detected. Should be pretty easy and straightforward to be used ( I hope 🙂)
Link at https://github.com/LucaIaco/SwipeableView
proof of concept:
Hope it helps
If you are looking for a SwiftUI 2.0 solution that uses View Struct, here it is:
https://github.com/kenfai/KavSoft-Tutorials-iOS/tree/main/MapsBottomSheet
Maybe you can try my answer https://github.com/AnYuan/AYPannel, inspired by Pulley. Smooth transition from moving the drawer to scrolling the list. I added a pan gesture on the container scroll view, and set shouldRecognizeSimultaneouslyWithGestureRecognizer to return YES. More detail in my github link above. Wish to help.

Material "pull to refresh" in Swift?

I am trying to achieve pull to refresh in a WKWebView like in this Material pull to refresh GIF. Because we want to include websites that already have a html navigation bar, we need to keep the web view fix when dragging down. I found pull to refresh (UIRefreshControl) to table views and web views but the views go down as the user drags down.
I set the delegate to scrollView property of the web view and receive notifications. When I drag near to top and get to 0 (vertical scroll view offset) I can disable the scroll view, enable the pan gesture. But to actually move the custom spinner view I need a second touch.
The method func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent?) doesn't work because I have a scroll view over.
private func gestures() {
self.panGesture = UIPanGestureRecognizer(
target: self,
action: "panGestureCaptured:"
)
self.panGesture!.enabled = false
self.webView.addGestureRecognizer(self.panGesture!)
}
func scrollViewDidScroll(scrollView: UIScrollView) {
if scrollView.contentOffset.y <= 0 {
self.view.layoutIfNeeded()
self.spinnerTopLayoutConstraint?.constant = -scrollView.contentOffset.y
}
}
func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if scrollView.panGestureRecognizer.translationInView(scrollView.superview).y > 0 { // dragging down
if scrollView.contentOffset.y == 0 {
self.webView!.scrollView.scrollEnabled = false
// self.webView!.scrollView.canCancelContentTouches = true
self.panGesture!.enabled = true
// self.refreshWebView()
}
self.showNavigationItems()
} else { // dragging up
self.hideNavigationItems()
}
}
Thanks to #Darko: the idea was to use the panGesture property of the scroll view (also property of the web view).
private func gestures() {
self.webView.scrollView.panGestureRecognizer.addTarget(
self,
action: "panGestureCaptured:"
)
}
func panGestureCaptured(gesture: UIGestureRecognizer) {
let touchPoint = gesture.locationInView(self.webView)
print("touchPoint: \(touchPoint)")
print("panGestureCaptured scrollView offset \(self.webView!.scrollView.contentOffset.y)")
if self.webView!.scrollView.contentOffset.y == 0 {
if self.webView!.scrollView.panGestureRecognizer.state == UIGestureRecognizerState.Changed {
if touchPoint.y < self.webView!.frame.height * 0.3 {
self.spinnerTopLayoutConstraint?.constant = touchPoint.y
} else {
self.spinnerTopLayoutConstraint?.constant = self.webView!.frame.height * 0.3
}
} else if self.webView!.scrollView.panGestureRecognizer.state == UIGestureRecognizerState.Ended {
self.spinnerTopLayoutConstraint?.constant = UIApplication.sharedApplication().statusBarFrame.height + 20
}
}
}
WebView's scroll view already has a pan recognizer (webView.scrollView.panGestureRecognizer) so you could listen to this one (with addTarget) and disable bouncing on the scroll view. In this way you don't need to disable/enable the pan recognizer.

UIPercentDrivenInteractiveTransition doesn't get to animation's completion on fast gesture

I have created an interactive transition. My func animateTransition(transitionContext: UIViewControllerContextTransitioning) is quite normal, I get the container UIView, I add the two UIViewControllers and then I do the animation changes in a UIView.animateWithDuration(duration, animations, completion).
I add a UIScreenEdgePanGestureRecognizer to my from UIViewController. It works well except when I do a very quick pan.
In that last scenario, the app is not responsive, still on the same UIViewController (the transition seems not to have worked) but the background tasks run. When I run the Debug View Hierarchy, I see the new UIViewController instead of the previous one, and the previous one (at least its UIView) stands where it is supposed to stand at the end of the transition.
I did some print out and check points and from that I can say that when the problem occurs, the animation's completion (the one in my animateTransition method) is not reached, so I cannot call the transitionContext.completeTransition method to complete or not the transition.
I could see as well that the pan goes sometimes from UIGestureRecognizerState.Began straight to UIGestureRecognizerState.Ended without going through UIGestureRecognizerState.Changed.
When it goes through UIGestureRecognizerState.Changed, both the translation and the velocity stay the same for every UIGestureRecognizerState.Changed states.
EDIT :
Here is the code:
animateTransition method
func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
self.transitionContext = transitionContext
let containerView = transitionContext.containerView()
let screens: (from: UIViewController, to: UIViewController) = (transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)!, transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)!)
let parentViewController = presenting ? screens.from : screens.to
let childViewController = presenting ? screens.to : screens.from
let parentView = parentViewController.view
let childView = childViewController.view
// positionning the "to" viewController's view for the animation
if presenting {
offStageChildViewController(childView)
}
containerView.addSubview(parentView)
containerView.addSubview(childView)
let duration = transitionDuration(transitionContext)
UIView.animateWithDuration(duration, animations: {
if self.presenting {
self.onStageViewController(childView)
self.offStageParentViewController(parentView)
} else {
self.onStageViewController(parentView)
self.offStageChildViewController(childView)
}}, completion: { finished in
if transitionContext.transitionWasCancelled() {
transitionContext.completeTransition(false)
} else {
transitionContext.completeTransition(true)
}
})
}
Gesture and gesture handler:
weak var fromViewController: UIViewController! {
didSet {
let screenEdgePanRecognizer = UIScreenEdgePanGestureRecognizer(target: self, action: "presentingViewController:")
screenEdgePanRecognizer.edges = edge
fromViewController.view.addGestureRecognizer(screenEdgePanRecognizer)
}
}
func presentingViewController(pan: UIPanGestureRecognizer) {
let percentage = getPercentage(pan)
switch pan.state {
case UIGestureRecognizerState.Began:
interactive = true
presentViewController(pan)
case UIGestureRecognizerState.Changed:
updateInteractiveTransition(percentage)
case UIGestureRecognizerState.Ended:
interactive = false
if finishPresenting(pan, percentage: percentage) {
finishInteractiveTransition()
} else {
cancelInteractiveTransition()
}
default:
break
}
}
Any idea what might happen?
EDIT 2:
Here are the undisclosed methods:
override func getPercentage(pan: UIPanGestureRecognizer) -> CGFloat {
let translation = pan.translationInView(pan.view!)
return abs(translation.x / pan.view!.bounds.width)
}
override func onStageViewController(view: UIView) {
view.transform = CGAffineTransformIdentity
}
override func offStageParentViewController(view: UIView) {
view.transform = CGAffineTransformMakeTranslation(-view.bounds.width / 2, 0)
}
override func offStageChildViewController(view: UIView) {
view.transform = CGAffineTransformMakeTranslation(view.bounds.width, 0)
}
override func presentViewController(pan: UIPanGestureRecognizer) {
let location = pan.locationInView((fromViewController as! MainViewController).tableView)
let indexPath = (fromViewController as! MainViewController).tableView.indexPathForRowAtPoint(location)
if indexPath == nil {
pan.state = .Failed
return
}
fromViewController.performSegueWithIdentifier("chartSegue", sender: pan)
}
I remove the "over" adding lines => didn't fix it
I added updateInteractiveTransition in .Began, in .Ended, in both => didn't fix it
I turned on shouldRasterize on the layer of the view of my toViewController and let it on all the time => didn't fix it
But the question is why, when doing a fast interactive gesture, is it not responding quickly enough
It actually works with a fast interactive gesture as long as I leave my finger long enough. For example, if I pan very fast on more than (let say) 1cm, it's ok. It's not ok if I pan very fast on a small surface (let say again) less than 1cm
Possible candidates include the views being animated are too complicated (or have complicated effects like shading)
I thought about a complicated view as well but I don't think my view is really complicated. There are a bunch of buttons and labels, a custom UIControl acting as a segmented segment, a chart (that is loaded once the controller appeared) and a xib is loaded inside the viewController.
Ok I just created a project with the MINIMUM classes and objects in order to trigger the problem. So to trigger it, you just do a fast and brief swipe from the right to the left.
What I noticed is that it works pretty easily the first time but if you drag the view controller normally the first time, then it get much harder to trigger it (even impossible?). While in my full project, it doesn't really matter.
When I was diagnosing this problem, I noticed that the gesture's change and ended state events were taking place before animateTransition even ran. So the animation was canceled/finished before it even started!
I tried using GCD animation synchronization queue to ensure that the updating of the UIPercentDrivenInterativeTransition doesn't happen until after `animate:
private let animationSynchronizationQueue = dispatch_queue_create("com.domain.app.animationsynchronization", DISPATCH_QUEUE_SERIAL)
I then had a utility method to use this queue:
func dispatchToMainFromSynchronizationQueue(block: dispatch_block_t) {
dispatch_async(animationSynchronizationQueue) {
dispatch_sync(dispatch_get_main_queue(), block)
}
}
And then my gesture handler made sure that changes and ended states were routed through that queue:
func handlePan(gesture: UIPanGestureRecognizer) {
switch gesture.state {
case .Began:
dispatch_suspend(animationSynchronizationQueue)
fromViewController.performSegueWithIdentifier("segueID", sender: gesture)
case .Changed:
dispatchToMainFromSynchronizationQueue() {
self.updateInteractiveTransition(percentage)
}
case .Ended:
dispatchToMainFromSynchronizationQueue() {
if isOkToFinish {
self.finishInteractiveTransition()
} else {
self.cancelInteractiveTransition()
}
}
default:
break
}
}
So, I have the gesture recognizer's .Began state suspend that queue, and I have the animation controller resume that queue in animationTransition (ensuring that the queue starts again only after that method runs before the gesture proceeds to try to update the UIPercentDrivenInteractiveTransition object.
Have the same issue, tried to use serialQueue.suspend()/resume(), does not work.
This issue is because when pan gesture is too fast, end state is earlier than animateTransition starts, then context.completeTransition can not get run, the whole animation is messed up.
My solution is forcing to run context.completeTransition when this situation happened.
For example, I have two classes:
class SwipeInteractor: UIPercentDrivenInteractiveTransition {
var interactionInProgress = false
...
}
class AnimationController: UIViewControllerAnimatedTransitioning {
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
if !swipeInteractor.interactionInProgress {
DispatchQueue.main.asyncAfter(deadline: .now()+transitionDuration) {
if context.transitionWasCancelled {
toView?.removeFromSuperview()
} else {
fromView?.removeFromSuperview()
}
context.completeTransition(!context.transitionWasCancelled)
}
}
...
}
...
}
interactionInProgress is set to true when gesture began, set to false when gesture ends.
I had a similar problem, but with programmatic animation triggers not triggering the animation completion block. My solution was like Sam's, except instead of dispatching after a small delay, manually call finish on the UIPercentDrivenInteractiveTransition instance.
class SwipeInteractor: UIPercentDrivenInteractiveTransition {
var interactionInProgress = false
...
}
class AnimationController: UIViewControllerAnimatedTransitioning {
private var swipeInteractor: SwipeInteractor
..
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
...
if !swipeInteractor.interactionInProgress {
swipeInteractor.finish()
}
...
UIView.animateWithDuration(...)
}
}

Resources