blob: 71bc9da0b5f2a7b19b15c9133dedab7d8ccf2508 [file] [log] [blame]
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "ios/chrome/browser/ui/gestures/view_revealing_vertical_pan_handler.h"
#import "base/check_op.h"
#include "base/logging.h"
#import "base/notreached.h"
#include "base/numerics/ranges.h"
#import "ios/chrome/browser/ui/gestures/layout_switcher.h"
#import "ios/chrome/browser/ui/gestures/pan_handler_scroll_view.h"
#include "ios/chrome/browser/ui/util/ui_util.h"
#import "ios/chrome/browser/ui/util/uikit_ui_util.h"
#if !defined(__has_feature) || !__has_feature(objc_arc)
#error "This file requires ARC support."
#endif
namespace {
// The weight multiplier of the gesture velocity used in the equation that
// determines whether the translation and velocity of the gesture are enough
// to trigger revealing the view. The threshold is the percentage of the height
// of the view that must be "traveled" by the gesture to trigger the transition.
const CGFloat kVelocityWeight = 0.5f;
const CGFloat kRevealThreshold = 1 / 3.0f;
// Duration of the animation to reveal/hide the view.
const CGFloat kAnimationDuration = 0.25f;
// The 3 stages or steps of the transitions handled by the view revealing
// vertical pan handler class.
enum class LayoutTransitionState {
// The layout is not transitioning.
Inactive,
// The layout is actively transitioning.
Active,
// The layout transition is in the process of finishing. The UIKit collection
// view transition API breaks if a transition is finished again in the time
// between when the finish is requested and the UIKit animations and cleanup
// are completed.
Finishing,
};
} // namespace
@interface ViewRevealingVerticalPanHandler ()
// Privately redeclare |currentState| as readwrite.
@property(nonatomic, readwrite, assign) ViewRevealState currentState;
// The state that the currentState will be set to if the transition animation
// completes with its REVERSED property set to NO.
@property(nonatomic, assign) ViewRevealState nextState;
// The property animator for revealing the view.
@property(nonatomic, strong) UIViewPropertyAnimator* animator;
// Total distance between the Peeked state and Revealed state. Equal to
// |revealedHeight| - |peekedHeight|.
@property(nonatomic, assign, readonly) CGFloat remainingHeight;
// Height of the cover view (the view in front of the view that will be
// revealed) that will still be visible after the remaining reveal transition.
@property(nonatomic, assign, readonly) CGFloat revealedCoverHeight;
// The progress of the animator.
@property(nonatomic, assign) CGFloat progressWhenInterrupted;
// Set of UI elements which are animated during view reveal transitions.
@property(nonatomic, strong) NSHashTable<id<ViewRevealingAnimatee>>* animatees;
// The current state tracking whether the revealed view is undergoing a
// transition of layout. This is |::Inactive| initially. It is set to |::Active|
// when the transition layout is created. It is set to |::Finishing| when the
// layout transition should start to finish. (This takes time because of
// finishing animations/UIKit restrictions). Finally, in the transition's
// completion block, this is set back to |::Inactive|.
@property(nonatomic, assign) LayoutTransitionState layoutTransitionState;
// Whether new pan gestures should be handled. Set to NO when a pan gesture ends
// and set to YES when a pan gesture starts while layoutInTransition is NO.
@property(nonatomic, assign) BOOL gesturesEnabled;
// The contentOffset during the previous call to -scrollViewDidScroll:. Used to
// keep the contentOffset the same during successive calls to
// -scrollViewDidScroll:.
@property(nonatomic, assign) CGPoint lastScrollOffset;
// Holds the gesture recognizer that is currently in progess. Any other
// gestures received while one is active will be ignored.
@property(nonatomic, weak) UIGestureRecognizer* currentRecognizer;
@end
@implementation ViewRevealingVerticalPanHandler
- (instancetype)initWithPeekedHeight:(CGFloat)peekedHeight
revealedCoverHeight:(CGFloat)revealedCoverHeight
baseViewHeight:(CGFloat)baseViewHeight
initialState:(ViewRevealState)initialState {
if (self = [super init]) {
_peekedHeight = peekedHeight;
_revealedCoverHeight = revealedCoverHeight;
_baseViewHeight = baseViewHeight;
_revealedHeight = baseViewHeight - revealedCoverHeight;
_remainingHeight = _revealedHeight - peekedHeight;
_currentState = initialState;
_animatees = [NSHashTable weakObjectsHashTable];
_layoutTransitionState = LayoutTransitionState::Inactive;
}
return self;
}
- (void)handlePanGesture:(UIPanGestureRecognizer*)gesture {
// Start handling gestures again once the layout is no longer transitioning.
if (self.layoutTransitionState == LayoutTransitionState::Inactive &&
gesture.state == UIGestureRecognizerStateBegan) {
self.gesturesEnabled = YES;
}
if (!self.gesturesEnabled)
return;
self.currentRecognizer = gesture;
CGFloat translationY = [gesture translationInView:gesture.view.superview].y;
if (gesture.state == UIGestureRecognizerStateBegan) {
[self panGestureBegan];
} else if (gesture.state == UIGestureRecognizerStateChanged) {
[self panGestureChangedWithTranslation:translationY];
} else if (gesture.state == UIGestureRecognizerStateEnded) {
CGFloat velocityY = [gesture velocityInView:gesture.view.superview].y;
[self panGestureEndedWithTranslation:translationY velocity:velocityY];
self.currentRecognizer = nil;
} else if (gesture.state == UIGestureRecognizerStateCancelled) {
self.currentRecognizer = nil;
}
}
- (void)addAnimatee:(id<ViewRevealingAnimatee>)animatee {
[self.animatees addObject:animatee];
// Make sure the newly added animatee is in the correct state.
[animatee willAnimateViewRevealFromState:self.currentState
toState:self.currentState];
[animatee animateViewReveal:self.currentState];
[animatee didAnimateViewReveal:self.currentState];
}
- (void)setBaseViewHeight:(CGFloat)baseViewHeight {
_baseViewHeight = baseViewHeight;
_revealedHeight = baseViewHeight - _revealedCoverHeight;
_remainingHeight = _revealedHeight - _peekedHeight;
}
- (void)setNextState:(ViewRevealState)state animated:(BOOL)animated {
self.nextState = state;
// If the layout is currently finishing its transition, a new transition
// cannot be started. Instead, re-call this method once the transition has
// finished.
if (self.layoutTransitionState == LayoutTransitionState::Finishing) {
return;
}
[self createAnimatorIfNeeded];
if (animated) {
[self.animator startAnimation];
} else {
[self.animator setFractionComplete:1];
[self.animator stopAnimation:NO];
[self.animator finishAnimationAtPosition:UIViewAnimatingPositionEnd];
}
// If the layout is currently changing, finish the transition.
if (self.layoutTransitionState == LayoutTransitionState::Active) {
[self didTransitionToLayoutSuccessfully:YES];
self.gesturesEnabled = NO;
}
}
#pragma mark - Private Methods: Animating
// Called right before an animation block to warn all animatees of a transition
// from the current view reveal state.
- (void)willAnimateViewReveal {
for (id<ViewRevealingAnimatee> animatee in self.animatees) {
[animatee willAnimateViewRevealFromState:self.currentState
toState:self.nextState];
}
}
// Called inside an animation block to animate all animatees to the next view
// reveal state.
- (void)animateToNextViewRevealState {
for (id<ViewRevealingAnimatee> animatee in self.animatees) {
[animatee animateViewReveal:self.nextState];
}
}
// Called inside the completion block of the current animation. Takes as
// argument the state to which the animatees did animate to.
- (void)didAnimateViewReveal:(ViewRevealState)viewRevealState {
for (id<ViewRevealingAnimatee> animatee in self.animatees) {
[animatee didAnimateViewReveal:viewRevealState];
}
}
// Creates the animation for the transition to the next view reveal state, if
// different from the current state.
- (void)createAnimatorIfNeeded {
if (self.currentState == self.nextState) {
return;
}
[self willAnimateViewReveal];
[self.animator stopAnimation:YES];
__weak ViewRevealingVerticalPanHandler* weakSelf = self;
self.animator = [[UIViewPropertyAnimator alloc]
initWithDuration:kAnimationDuration
dampingRatio:1
animations:^() {
[weakSelf animateToNextViewRevealState];
}];
[self.animator addCompletion:^(UIViewAnimatingPosition finalPosition) {
if (!weakSelf.animator.reversed) {
weakSelf.currentState = weakSelf.nextState;
}
[weakSelf didAnimateViewReveal:weakSelf.currentState];
}];
[self.animator pauseAnimation];
[self createLayoutTransitionIfNeeded];
}
// Creates a transition layout in the revealed view if going from Peeked to
// Revealed state or vice-versa.
- (void)createLayoutTransitionIfNeeded {
if (self.layoutTransitionState == LayoutTransitionState::Finishing) {
return;
}
if (self.layoutTransitionState == LayoutTransitionState::Active) {
// Cancel the current layout transition.
[self.layoutSwitcherProvider.layoutSwitcher
didUpdateTransitionLayoutProgress:0];
[self didTransitionToLayoutSuccessfully:NO];
return;
}
if (self.nextState == ViewRevealState::Revealed) {
[self willTransitionToLayout:LayoutSwitcherState::Grid];
} else if (self.currentState == ViewRevealState::Revealed &&
(self.nextState == ViewRevealState::Peeked ||
self.nextState == ViewRevealState::Hidden)) {
[self willTransitionToLayout:LayoutSwitcherState::Horizontal];
}
}
// Notifies the layout switcher that a layout transition should happen.
- (void)willTransitionToLayout:(LayoutSwitcherState)nextState {
// Don't do anything if there isn't a layout switcher available. Especially
// don't change the |layoutTransitionState|.
if (!self.layoutSwitcherProvider.layoutSwitcher) {
return;
}
DCHECK_EQ(self.layoutTransitionState, LayoutTransitionState::Inactive);
auto completion = ^(BOOL completed, BOOL finished) {
if (self.nextState == self.currentState ||
self.animator.state == UIViewAnimatingStateActive) {
self.layoutTransitionState = LayoutTransitionState::Inactive;
return;
}
// If current state doesn't match the next state and the animator is not
// active, then next state has been changed while the transition is
// finishing. Start a new programmatic transition to the correct final
// state. Triggering a transiton from inside the completion block of a
// transition seems to cause the new transition's completion block to never
// fire, so do that on the next run loop.
dispatch_async(dispatch_get_main_queue(), ^{
self.layoutTransitionState = LayoutTransitionState::Inactive;
// Make sure the next state hasn't changed.
if (self.nextState == self.currentState ||
self.animator.state == UIViewAnimatingStateActive) {
return;
}
[self setNextState:self.nextState animated:YES];
});
};
[self.layoutSwitcherProvider.layoutSwitcher
willTransitionToLayout:nextState
completion:completion];
self.layoutTransitionState = LayoutTransitionState::Active;
}
// Notifies the layout switcher that a layout transition finished with
// |success|.
- (void)didTransitionToLayoutSuccessfully:(BOOL)success {
// Don't do anything if there isn't a layout switcher available. Especially
// don't change the |layoutTransitionState|.
if (!self.layoutSwitcherProvider.layoutSwitcher) {
return;
}
DCHECK_EQ(self.layoutTransitionState, LayoutTransitionState::Active);
self.layoutTransitionState = LayoutTransitionState::Finishing;
[self.layoutSwitcherProvider.layoutSwitcher
didTransitionToLayoutSuccessfully:success];
}
// Initiates a transition if there isn't already one running
- (void)animateTransitionIfNeeded {
if (self.animator.isRunning) {
self.animator.reversed = NO;
return;
}
self.nextState = ViewRevealState::Peeked;
// If the current state is Peeked, the animator is not created just yet
// because the gesture might go in one of two directions. It will only be
// created after the gesture changes and its translation direction is
// determined.
[self createAnimatorIfNeeded];
}
#pragma mark - Private Methods: Pan handling
// Returns whether the gesture's translation and velocity were enough to trigger
// the revealing of a view with the specified height (partial reveal height or
// remaining reveal height).
- (BOOL)shouldRevealWithTranslation:(CGFloat)translation
velocity:(CGFloat)velocity
height:(CGFloat)height {
CGFloat progress = self.progressWhenInterrupted +
(translation + velocity * kVelocityWeight) / height;
return progress > kRevealThreshold;
}
// Returns whether the gesture's translation and velocity were enough to trigger
// the hiding of a view with the specified height (partial reveal height or
// remaining reveal height).
- (BOOL)shouldHideWithTranslation:(CGFloat)translation
velocity:(CGFloat)velocity
height:(CGFloat)height {
return [self shouldRevealWithTranslation:translation
velocity:velocity
height:-height];
}
// Returns what the next state should be, given the translation, velocity of
// the gesture, and the current state.
- (ViewRevealState)nextStateWithTranslation:(CGFloat)translation
Velocity:(CGFloat)velocity {
switch (self.currentState) {
case ViewRevealState::Hidden:
return [self shouldRevealWithTranslation:translation
velocity:velocity
height:self.peekedHeight]
? ViewRevealState::Peeked
: ViewRevealState::Hidden;
case ViewRevealState::Revealed:
return [self shouldHideWithTranslation:translation
velocity:velocity
height:self.remainingHeight]
? ViewRevealState::Peeked
: ViewRevealState::Revealed;
case ViewRevealState::Peeked:
if ([self shouldHideWithTranslation:translation
velocity:velocity
height:self.peekedHeight]) {
return ViewRevealState::Hidden;
}
if ([self shouldRevealWithTranslation:translation
velocity:velocity
height:self.remainingHeight]) {
return ViewRevealState::Revealed;
}
return self.currentState;
}
}
// Updates the progress of the animator, depending on the current and next
// states.
- (void)updateAnimatorProgress:(CGFloat)translation {
CGFloat progress;
switch (self.currentState) {
case ViewRevealState::Peeked: {
CGFloat height =
(self.nextState == ViewRevealState::Hidden ? -self.peekedHeight
: self.remainingHeight);
progress = translation / height;
break;
}
case ViewRevealState::Hidden:
progress = translation / self.peekedHeight;
break;
case ViewRevealState::Revealed:
progress = translation / (-self.remainingHeight);
break;
}
progress += self.progressWhenInterrupted;
progress = base::ClampToRange<CGFloat>(progress, 0, 1);
self.animator.fractionComplete = progress;
if (self.layoutTransitionState == LayoutTransitionState::Active) {
[self.layoutSwitcherProvider.layoutSwitcher
didUpdateTransitionLayoutProgress:progress];
}
}
// Handles the start of the pan gesture.
- (void)panGestureBegan {
[self animateTransitionIfNeeded];
[self.animator pauseAnimation];
self.progressWhenInterrupted = self.animator.fractionComplete;
}
// Handles the movement after the start of the gesture.
- (void)panGestureChangedWithTranslation:(CGFloat)translation {
if (self.currentState == ViewRevealState::Peeked) {
// If the gesture translation passes through the midpoint (the point where
// the state is Peeked), the current animation should be stopped and a new
// one created.
if (translation > 0) {
// The transition state may be inactive even while panning when going
// betwteen Hidden and Peeked states, as those two states don't involve a
// layout transition.
if (self.nextState != ViewRevealState::Revealed &&
self.layoutTransitionState == LayoutTransitionState::Inactive) {
self.nextState = ViewRevealState::Revealed;
[self createAnimatorIfNeeded];
}
} else {
if (self.nextState != ViewRevealState::Hidden) {
self.nextState = ViewRevealState::Hidden;
[self createAnimatorIfNeeded];
}
}
}
[self updateAnimatorProgress:translation];
}
// Handles the end of the gesture.
- (void)panGestureEndedWithTranslation:(CGFloat)translation
velocity:(CGFloat)velocity {
self.animator.reversed =
(self.currentState == [self nextStateWithTranslation:translation
Velocity:velocity]);
if (self.animator.reversed) {
self.nextState = self.currentState;
}
[self.animator continueAnimationWithTimingParameters:nil durationFactor:1];
// If the layout is currently changing, finish the transition.
if (self.layoutTransitionState == LayoutTransitionState::Active) {
[self didTransitionToLayoutSuccessfully:!self.animator.reversed];
self.gesturesEnabled = NO;
}
}
#pragma mark - UIScrollViewDelegate
- (void)scrollViewWillBeginDragging:(UIScrollView*)scrollView {
PanHandlerScrollView* view =
[[PanHandlerScrollView alloc] initWithScrollView:scrollView];
[self panHandlerScrollViewWillBeginDragging:view];
}
- (void)scrollViewDidScroll:(UIScrollView*)scrollView {
PanHandlerScrollView* view =
[[PanHandlerScrollView alloc] initWithScrollView:scrollView];
[self panHandlerScrollViewDidScroll:view];
}
- (void)scrollViewWillEndDragging:(UIScrollView*)scrollView
withVelocity:(CGPoint)velocity
targetContentOffset:(inout CGPoint*)targetContentOffset {
PanHandlerScrollView* view =
[[PanHandlerScrollView alloc] initWithScrollView:scrollView];
[self panHandlerScrollViewWillEndDragging:view
withVelocity:velocity
targetContentOffset:targetContentOffset];
}
- (void)scrollViewDidEndDragging:(UIScrollView*)scrollView
willDecelerate:(BOOL)decelerate {
// No-op.
}
#pragma mark - CRWWebViewScrollViewProxyObserver
- (void)webViewScrollViewWillBeginDragging:
(CRWWebViewScrollViewProxy*)webViewScrollViewProxy {
PanHandlerScrollView* view = [[PanHandlerScrollView alloc]
initWithWebViewScrollViewProxy:webViewScrollViewProxy];
[self panHandlerScrollViewWillBeginDragging:view];
}
- (void)webViewScrollViewDidScroll:
(CRWWebViewScrollViewProxy*)webViewScrollViewProxy {
PanHandlerScrollView* view = [[PanHandlerScrollView alloc]
initWithWebViewScrollViewProxy:webViewScrollViewProxy];
[self panHandlerScrollViewDidScroll:view];
}
- (void)webViewScrollViewWillEndDragging:
(CRWWebViewScrollViewProxy*)webViewScrollViewProxy
withVelocity:(CGPoint)velocity
targetContentOffset:(inout CGPoint*)targetContentOffset {
PanHandlerScrollView* view = [[PanHandlerScrollView alloc]
initWithWebViewScrollViewProxy:webViewScrollViewProxy];
[self panHandlerScrollViewWillEndDragging:view
withVelocity:velocity
targetContentOffset:targetContentOffset];
}
#pragma mark - UIScrollViewDelegate + CRWWebViewScrollViewProxyObserver
- (void)panHandlerScrollViewWillBeginDragging:
(PanHandlerScrollView*)scrollView {
if (self.currentRecognizer &&
self.currentRecognizer != scrollView.panGestureRecognizer) {
return;
}
switch (self.currentState) {
case ViewRevealState::Hidden: {
// The transition out of hidden state can only start if the scroll view
// starts dragging from the top.
CGFloat contentOffsetY =
scrollView.contentOffset.y + scrollView.contentInset.top;
if (!AreCGFloatsEqual(contentOffsetY, 0.0)) {
return;
}
break;
}
case ViewRevealState::Peeked:
break;
case ViewRevealState::Revealed:
// The scroll views should be covered in Revealed state, so should not
// be able to be scrolled.
NOTREACHED();
}
self.currentRecognizer = scrollView.panGestureRecognizer;
[self panGestureBegan];
self.lastScrollOffset = scrollView.contentOffset;
}
- (void)panHandlerScrollViewDidScroll:(PanHandlerScrollView*)scrollView {
// Early return if there is no current recognizer or one that does not match
// this scroll view's recognizer. The first can happen when the scroll view
// scrolls after the user lifts their finger. This should not be handled as
// these methods are only approximating the actual pan gesture handling from
// above. The second can happen if the user scrolls and uses one of the pan
// gestures simultaneously.
if (self.currentRecognizer != scrollView.panGestureRecognizer) {
return;
}
UIPanGestureRecognizer* gesture = scrollView.panGestureRecognizer;
CGFloat translationY = [gesture translationInView:gesture.view.superview].y;
// When in Peeked state, scrolling can only transition to Hidden state.
if (self.currentState == ViewRevealState::Peeked && translationY > 0) {
translationY = 0;
}
[self panGestureChangedWithTranslation:translationY];
// During the transition, the ViewRevealingAnimatees should be moving, not the
// scroll view.
if (self.animator.fractionComplete > 0 &&
self.animator.fractionComplete < 1) {
CGPoint currentScrollOffset = scrollView.contentOffset;
currentScrollOffset.y = self.lastScrollOffset.y;
scrollView.contentOffset = currentScrollOffset;
}
self.lastScrollOffset = scrollView.contentOffset;
}
- (void)panHandlerScrollViewWillEndDragging:(PanHandlerScrollView*)scrollView
withVelocity:(CGPoint)velocity
targetContentOffset:
(inout CGPoint*)targetContentOffset {
// Early return if there is no current recognizer or one that does not match
// this scroll view's recognizer. The first can happen when the scroll view
// scrolls after the user lifts their finger. This should not be handled as
// these methods are only approximating the actual pan gesture handling from
// above. The second can happen if the user scrolls and uses one of the pan
// gestures simultaneously.
if (self.currentRecognizer != scrollView.panGestureRecognizer) {
return;
}
self.currentRecognizer = nil;
if (self.currentState == ViewRevealState::Hidden &&
self.animator.state != UIViewAnimatingStateActive) {
return;
}
UIPanGestureRecognizer* gesture = scrollView.panGestureRecognizer;
CGFloat translationY = [gesture translationInView:gesture.view.superview].y;
CGFloat velocityY = [gesture velocityInView:gesture.view.superview].y;
// When in Peeked state, scrolling can only transition to Hidden state.
if (self.currentState == ViewRevealState::Peeked && translationY > 0) {
translationY = 0;
velocityY = 0;
}
[self panGestureEndedWithTranslation:translationY velocity:velocityY];
}
#pragma mark - UIGestureRecognizerDelegate
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer*)gestureRecognizer {
if (self.currentRecognizer) {
return NO;
}
return YES;
}
@end