12

There's a behavior in the Line messenger app (the de facto messenger app in Japan) that I'm trying to emulate.

Basically, they have a modal view controller with a scroll view inside. When the scroll action reaches the top of its content, the view controller seamlessly switches to an interactive dismissal animation. Also, when the gesture returns the view to the top of the screen, control is returned to the scroll view.

Here's a gif of how it looks.

demo gif

For the life of me, I can't figure out how they did it. I've tried a few different methods, but they've all failed, and I'm out of ideas. Can anyone point me in the right direction?

EDIT2

To clarify, the behavior that I want to emulate isn't just simply dragging the window down. I can do that, no problem.

I want to know how the same scroll gesture (without lifting the finger) triggers the dismissal transition and then transfers control back to the scroll view after the view has been dragged back to the original position.

This is the part that I can't figure out.

End EDIT2

EDIT1

Here's what I have so far. I was able to use the scroll view delegate methods to add a target-selector that handles the regular dismissal animation, but it still doesn't work as expected.

I create a UIViewController with a UIWebView as a property. Then I put it in a UINavigationController, which is presented modally.

The navigation controller uses animation/transition controllers for the regular interactive dismissal (which can be done by gesturing over the navigation bar).

From here, everything works fine, but the dismissal can't be triggered from the scroll view.

NavigationController.h

@interface NavigationController : UINavigationController <UIViewControllerTransitioningDelegate>

@property (nonatomic, strong) UIPanGestureRecognizer *gestureRecog;

- (void)handleGesture:(UIPanGestureRecognizer*)gestureRecognizer;

@end

NavigationController.m

#import "NavigationController.h"
#import "AnimationController.h"
#import "TransitionController.h"

@implementation NavigationController {
    AnimationController *_animator;
    TransitionController *_interactor;
}

- (instancetype)init {
    self = [super init];

    self.transitioningDelegate = self;

    _animator = [[AnimationController alloc] init];
    _interactor = [[TransitionController alloc] init];

    return self;
}

- (void)viewDidLoad {
    [super viewDidLoad];

    // Set the gesture recognizer
    self.gestureRecog = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handleGesture:)];
    [self.view addGestureRecognizer:_gestureRecog];
}

- (id<UIViewControllerInteractiveTransitioning>)interactionControllerForDismissal:(id<UIViewControllerAnimatedTransitioning>)animator {
    if (animator == _animator && _interactor.hasStarted) {
        return _interactor;
    }
    return nil;
}

- (id<UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed {
    if (dismissed == self || [self.viewControllers indexOfObject:dismissed] != NSNotFound) {
        return _animator;
    }
    return nil;
}

- (void)handleGesture:(UIPanGestureRecognizer *)gestureRecog {
    CGFloat threshold = 0.3f;

    CGPoint translation = [gestureRecog translationInView:self.view];
    CGFloat verticalMovement = translation.y / self.view.bounds.size.height;
    CGFloat downwardMovement = fmaxf(verticalMovement, 0.0f);
    CGFloat downwardMovementPercent = fminf(downwardMovement, 1.0f);

    switch (gestureRecog.state) {
        case UIGestureRecognizerStateBegan: {
            _interactor.hasStarted = YES;
            [self dismissViewControllerAnimated:YES completion:nil];
            break;
        }
        case UIGestureRecognizerStateChanged: {
            if (!_interactor.hasStarted) {
                _interactor.hasStarted = YES;
                [self dismissViewControllerAnimated:YES completion:nil];
            }
            _interactor.shouldFinish = downwardMovementPercent > threshold;
            [_interactor updateInteractiveTransition:downwardMovementPercent];
            break;
        }
        case UIGestureRecognizerStateCancelled: {
            _interactor.hasStarted = NO;
            [_interactor cancelInteractiveTransition];
            break;
        }
        case UIGestureRecognizerStateEnded: {
            _interactor.hasStarted = NO;
            if (_interactor.shouldFinish) {
                [_interactor finishInteractiveTransition];
            } else {
                [_interactor cancelInteractiveTransition];
            }
            break;
        }
        default: {
            break;
        }
    }
}

@end

Now, I have to get that gesture handling to trigger when the scroll view has reached the top. So, here's what I did in the view controller.

WebViewController.m

#import "WebViewController.h"
#import "NavigationController.h"

@interface WebViewController ()

@property (weak, nonatomic) IBOutlet UIWebView *webView;
@end

@implementation WebViewController {
    BOOL _isHandlingPan;
    CGPoint _topContentOffset;
}

- (void)viewDidLoad {
    [super viewDidLoad];

    [self.webView.scrollView setDelegate:self];
}    

- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
    if ((scrollView.panGestureRecognizer.state == UIGestureRecognizerStateBegan ||
         scrollView.panGestureRecognizer.state == UIGestureRecognizerStateChanged) &&
        ! _isHandlingPan &&
        scrollView.contentOffset.y < self.navigationController.navigationBar.translucent ? -64.0f : 0) {

        NSLog(@"Adding scroll target");

        _topContentOffset = CGPointMake(scrollView.contentOffset.x, self.navigationController.navigationBar.translucent ? -64.0f : 0);
        _isHandlingPan = YES;
        [scrollView.panGestureRecognizer addTarget:self action:@selector(handleGesture:)];
    }
}
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
    NSLog(@"Did End Dragging");
    if (_isHandlingPan) {
        NSLog(@"Removing action");
        _isHandlingPan = NO;
        [scrollView.panGestureRecognizer removeTarget:self action:@selector(handleGesture:)];
    }
}
- (void)handleGesture:(UIPanGestureRecognizer*)gestureRecognizer {
    [(NavigationController*)self.navigationController handleGesture:gestureRecognizer];
}

This still doesn't work quite right. Even during the dismissal animation, the scroll view is still scrolling with the gesture.

End EDIT1

ABeard89
  • 911
  • 9
  • 17
  • 1
    You need to add transitionViewController animations on the scroll offset value reached. Check out examples of view controller transitions. – iAviator Mar 25 '17 at 09:10
  • Hi, I guess you managed to do it now. What approach did you choose to get the correct behavior ? Thanks :) – AnthoPak May 14 '19 at 10:12
  • @AnthoPak Unfortunately, I never did get this to work. I’m sure it’s some clever use of delegate methods, though. – ABeard89 May 14 '19 at 11:49
  • 1
    @ABeard89 I found this https://stackoverflow.com/a/50060144/4894980 I think it could be adapted to be used for a fullscreen modal view controller. Could be a good start! :) – AnthoPak May 14 '19 at 11:52
  • @AnthoPak Thanks. That’s at least half of a solution. Still need to be able to cancel the dismissal and continue scrolling with the same pan gesture. But this is a good start, definitely. – ABeard89 May 14 '19 at 12:02
  • 1
    @ABeard89 Are you sure ? I've tried the demo and it seems able to handle cancelling of the dismissal while continue scrolling the scroll view. – AnthoPak May 14 '19 at 12:27
  • @AnthoPak I haven’t tried it yet. If you say the cancel works too, then I’ll definitely give it a shot! – ABeard89 May 14 '19 at 12:39
  • @ABeard89 To be honest I find this approach having a very good behavior. Tell me if you find any caveat but it's the best one I've found yet. – AnthoPak May 14 '19 at 13:55

2 Answers2

2

That is a custom interactive transition.

First, you need set transitioningDelegate of UIViewController

id<UIViewControllerTransitioningDelegate> transitioningDelegate;

Then implment these two method to

 //Asks your delegate for the transition animator object to use when dismissing a view controller.
 - animationControllerForDismissedController:
 //Asks your delegate for the interactive animator object to use when dismissing a view controller.
 - interactionControllerForDismissal:

When drag to top, you start the transition, you may use UIPercentDrivenInteractiveTransition to control the progress during scrolling.

You can also refer to the source code of ZFDragableModalTransition

Image of ZFDragableModalTransition

Leo
  • 24,596
  • 11
  • 71
  • 92
  • I edited my post with my code that has gotten me the most success. I guess I'm on the right track? Or is something flawed in my method? – ABeard89 Mar 25 '17 at 09:36
  • I guess I'll just look through that source code to see what my missing piece is. – ABeard89 Mar 25 '17 at 09:41
  • That CocoaPod doesn't do one thing that I want. And that's where I'm currently stuck. I want the same scroll gesture (without lifting the finger) to trigger the dismissal transition, and I want the scroll view to regain control after the view has been dragged back to the original position. This is the part that I can't figure out. – ABeard89 Mar 25 '17 at 10:09
1

As explained here the solution is quite complex. The person who answered, @trungduc, programmed a little demo published on github doing the sought behaviour. You can find it here.

The easiest way of making this work is to copy the 4 files found in /TestPanel/Presentation/ in the attached github repository, to your project. Then add the PanelAnimationControllerDelegate to your View Controller containing the scroll view (i.e. using the protocol).

Add the following to your View Controller, to satisfy the protocol:

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

Add this to deactivate the bouncing effect at the top:

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

Set scrollView.delegate = self

Before presenting your View Controller containing the scroll view set the following propreties to your View Controller:

ScrollViewController.transitioningDelegate = self.panelTransitioningDelegate
ScrollViewController.modalPresentationStyle = .custom

If you want to change the size of your ScrollViewController, you will need to comment out the override of the frameOfPresentedViewInContainerView in the PanelPresentationController file (one of the 4). Then in the presentationTransitionWillBegin method, you will need to set let frameOfPresentedViewInContainerView = self.frameOfPresentedViewInContainerView.insetBy(dx: 0, dy: 20) with the wanted inset of dx and dy.

Thank you to trungduc for this amazing solution!!