| Index: ios/chrome/browser/ui/side_swipe/side_swipe_navigation_view.mm
|
| diff --git a/ios/chrome/browser/ui/side_swipe/side_swipe_navigation_view.mm b/ios/chrome/browser/ui/side_swipe/side_swipe_navigation_view.mm
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..24dbefc133527e548c280c500346c74102ad9402
|
| --- /dev/null
|
| +++ b/ios/chrome/browser/ui/side_swipe/side_swipe_navigation_view.mm
|
| @@ -0,0 +1,424 @@
|
| +// Copyright 2015 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/side_swipe/side_swipe_navigation_view.h"
|
| +
|
| +#include <cmath>
|
| +
|
| +#include "base/logging.h"
|
| +#include "base/mac/objc_property_releaser.h"
|
| +#include "base/mac/scoped_nsobject.h"
|
| +#include "base/metrics/user_metrics.h"
|
| +#include "base/metrics/user_metrics_action.h"
|
| +#import "ios/chrome/browser/ui/side_swipe/side_swipe_util.h"
|
| +#import "ios/chrome/browser/ui/side_swipe_gesture_recognizer.h"
|
| +#include "ios/chrome/browser/ui/ui_util.h"
|
| +#import "ios/chrome/browser/ui/uikit_ui_util.h"
|
| +#import "ios/chrome/common/material_timing.h"
|
| +
|
| +namespace {
|
| +
|
| +enum class SwipeType { CHANGE_TABS, NAVIGATION };
|
| +
|
| +typedef struct {
|
| + CGFloat min;
|
| + CGFloat max;
|
| +} FloatRange;
|
| +
|
| +CGFloat MapValueToRange(FloatRange from, FloatRange to, CGFloat value) {
|
| + DCHECK(from.min < from.max);
|
| + if (value <= from.min)
|
| + return to.min;
|
| + if (value >= from.max)
|
| + return to.max;
|
| + const CGFloat fromDst = from.max - from.min;
|
| + const CGFloat toDst = to.max - to.min;
|
| + return to.min + ((value - from.min) / fromDst) * toDst;
|
| +}
|
| +
|
| +// The portion of the screen width a swipe must travel after which a navigation
|
| +// should be initiated.
|
| +const CGFloat kSwipeThreshold = 0.53;
|
| +
|
| +// Convert the velocity (which is measured in points per second) to points per
|
| +// |kSwipeVelocityFraction| of a second.
|
| +const CGFloat kSwipeVelocityFraction = 0.1;
|
| +
|
| +// Distance after which the arrow should animate in.
|
| +const CGFloat kArrowThreshold = 32;
|
| +
|
| +// Duration of the snapping animation when the selection bubble animates.
|
| +const CGFloat kSelectionSnappingAnimationDuration = 0.2;
|
| +
|
| +// Size of the selection circle.
|
| +const CGFloat kSelectionSize = 64.0;
|
| +
|
| +// Start scale of the selection circle.
|
| +const CGFloat kSelectionDownScale = 0.1875;
|
| +
|
| +// The final scale of the selection bubble when the threshold is met.
|
| +const CGFloat kSelectionAnimationScale = 23;
|
| +
|
| +// The duration of the animations played when the threshold is met.
|
| +const CGFloat kSelectionAnimationDuration = 0.5;
|
| +}
|
| +
|
| +@interface SideSwipeNavigationView () {
|
| + @private
|
| +
|
| + // The back or forward sprite image.
|
| + base::scoped_nsobject<UIImageView> arrowView_;
|
| +
|
| + // The selection bubble.
|
| + CAShapeLayer* selectionCircleLayer_;
|
| +
|
| + // If |NO| this is an edge gesture and navigation isn't possible. Don't show
|
| + // arrows and bubbles and don't allow navigate.
|
| + BOOL canNavigate_;
|
| +
|
| + // If |YES| arrowView_ is directionnal and must be rotated 180 degreed for the
|
| + // forward panes.
|
| + BOOL rotateForward_;
|
| +
|
| + base::mac::ObjCPropertyReleaser _propertyReleaser_SideSwipeNavigationView;
|
| +}
|
| +// Returns a newly allocated and configured selection circle shape.
|
| +- (CAShapeLayer*)newSelectionCircleLayer;
|
| +// Pushes the touch towards the edge because it's difficult to touch the very
|
| +// edge of the screen (touches tend to sit near x ~ 4).
|
| +- (CGPoint)adjustPointToEdge:(CGPoint)point;
|
| +@end
|
| +
|
| +@implementation SideSwipeNavigationView
|
| +
|
| +@synthesize targetView = targetView_;
|
| +
|
| +- (instancetype)initWithFrame:(CGRect)frame
|
| + withDirection:(UISwipeGestureRecognizerDirection)direction
|
| + canNavigate:(BOOL)canNavigate
|
| + image:(UIImage*)image
|
| + rotateForward:(BOOL)rotateForward {
|
| + self = [super initWithFrame:frame];
|
| + if (self) {
|
| + _propertyReleaser_SideSwipeNavigationView.Init(
|
| + self, [SideSwipeNavigationView class]);
|
| + self.backgroundColor = [UIColor colorWithWhite:90.0 / 256 alpha:1.0];
|
| +
|
| + canNavigate_ = canNavigate;
|
| + rotateForward_ = rotateForward;
|
| + if (canNavigate) {
|
| + image = [image imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
|
| + const CGRect imageSize = CGRectMake(0, 0, 24, 24);
|
| + arrowView_.reset([[UIImageView alloc] initWithImage:image]);
|
| + [arrowView_ setTintColor:[UIColor whiteColor]];
|
| + selectionCircleLayer_ = [self newSelectionCircleLayer];
|
| + [arrowView_ setFrame:imageSize];
|
| + }
|
| +
|
| + UIImage* shadowImage =
|
| + [UIImage imageNamed:@"side_swipe_navigation_content_shadow"];
|
| + CGRect borderFrame =
|
| + CGRectMake(0, 0, shadowImage.size.width, self.frame.size.height);
|
| + base::scoped_nsobject<UIImageView> border(
|
| + [[UIImageView alloc] initWithFrame:borderFrame]);
|
| + [border setImage:shadowImage];
|
| + [self addSubview:border];
|
| + if (direction == UISwipeGestureRecognizerDirectionRight) {
|
| + borderFrame.origin.x = frame.size.width - shadowImage.size.width;
|
| + [border setFrame:borderFrame];
|
| + [border setAutoresizingMask:UIViewAutoresizingFlexibleLeftMargin];
|
| + } else {
|
| + [border setTransform:CGAffineTransformMakeRotation(M_PI)];
|
| + [border setAutoresizingMask:UIViewAutoresizingFlexibleRightMargin];
|
| + }
|
| +
|
| + [self.layer addSublayer:selectionCircleLayer_];
|
| + [self setClipsToBounds:YES];
|
| + [self addSubview:arrowView_];
|
| + }
|
| + return self;
|
| +}
|
| +
|
| +- (CGPoint)adjustPointToEdge:(CGPoint)currentPoint {
|
| + CGFloat width = CGRectGetWidth(self.targetView.bounds);
|
| + CGFloat half = floor(width / 2);
|
| + CGFloat padding = floor(std::abs(currentPoint.x - half) / half);
|
| +
|
| + // Push towards the edges.
|
| + if (currentPoint.x > half)
|
| + currentPoint.x += padding;
|
| + else
|
| + currentPoint.x -= padding;
|
| +
|
| + // But don't go past the edges.
|
| + if (currentPoint.x < 0)
|
| + currentPoint.x = 0;
|
| + else if (currentPoint.x > width)
|
| + currentPoint.x = width;
|
| +
|
| + return currentPoint;
|
| +}
|
| +
|
| +- (void)updateFrameAndAnimateContents:(CGFloat)distance
|
| + forGesture:(SideSwipeGestureRecognizer*)gesture {
|
| + CGFloat width = CGRectGetWidth(self.targetView.bounds);
|
| +
|
| + // Immediately set frame size.
|
| + CGRect frame = self.frame;
|
| + if (gesture.direction == UISwipeGestureRecognizerDirectionRight) {
|
| + frame.size.width = self.targetView.frame.origin.x;
|
| + frame.origin.x = 0;
|
| + } else {
|
| + frame.origin.x = self.targetView.frame.origin.x + width;
|
| + frame.size.width = width - frame.origin.x;
|
| + }
|
| + [self setFrame:frame];
|
| +
|
| + // Move |selectionCircleLayer_| without animations.
|
| + CGRect bounds = self.bounds;
|
| + CGPoint center = CGPointMake(CGRectGetMidX(bounds), CGRectGetMidY(bounds));
|
| + [arrowView_ setCenter:AlignPointToPixel(center)];
|
| + [CATransaction begin];
|
| + [CATransaction setDisableActions:YES];
|
| + [selectionCircleLayer_ setPosition:center];
|
| + [CATransaction commit];
|
| +
|
| + CGFloat rotationStart = -M_PI_2;
|
| + CGFloat rotationEnd = 0;
|
| + if (gesture.direction == UISwipeGestureRecognizerDirectionLeft) {
|
| + if (rotateForward_) {
|
| + rotationStart = M_PI * 1.5;
|
| + rotationEnd = M_PI;
|
| + } else {
|
| + rotationStart = M_PI * 0.5;
|
| + rotationEnd = 0;
|
| + }
|
| + }
|
| + CGAffineTransform rotation = CGAffineTransformMakeRotation(MapValueToRange(
|
| + {0, kArrowThreshold}, {rotationStart, rotationEnd}, distance));
|
| + CGFloat scale = MapValueToRange({0, kArrowThreshold}, {0, 1}, distance);
|
| + [arrowView_ setTransform:CGAffineTransformScale(rotation, scale, scale)];
|
| +
|
| + // Animate selection bubbles dpending on distance.
|
| + [UIView beginAnimations:@"transform" context:NULL];
|
| + [UIView setAnimationDuration:kSelectionSnappingAnimationDuration];
|
| + if (distance < (width * kSwipeThreshold)) {
|
| + // Scale selection down.
|
| + selectionCircleLayer_.transform =
|
| + CATransform3DMakeScale(kSelectionDownScale, kSelectionDownScale, 1);
|
| + selectionCircleLayer_.opacity = 0;
|
| + [arrowView_ setAlpha:MapValueToRange({0, 64}, {0, 1}, distance)];
|
| + [arrowView_ setTintColor:[UIColor whiteColor]];
|
| + } else {
|
| + selectionCircleLayer_.transform = CATransform3DMakeScale(1, 1, 1);
|
| + selectionCircleLayer_.opacity = 0.75;
|
| + [arrowView_ setAlpha:1];
|
| + [arrowView_ setTintColor:self.backgroundColor];
|
| + }
|
| + [UIView commitAnimations];
|
| +}
|
| +
|
| +- (void)explodeSelection:(void (^)(void))block {
|
| + [CATransaction begin];
|
| + [CATransaction setCompletionBlock:^{
|
| + // Note that the animations below may complete at slightly different times
|
| + // resulting in frame(s) between animation completion and the transaction's
|
| + // completion handler that show the original state. To avoid this flicker,
|
| + // the animations use a fillMode forward and are not removed until the
|
| + // transaction completion handler is executed.
|
| + [selectionCircleLayer_ removeAnimationForKey:@"opacity"];
|
| + [selectionCircleLayer_ removeAnimationForKey:@"transform"];
|
| + [selectionCircleLayer_ setOpacity:0];
|
| + [arrowView_ setAlpha:0];
|
| + self.backgroundColor = [UIColor whiteColor];
|
| + block();
|
| +
|
| + }];
|
| +
|
| + CAMediaTimingFunction* timing =
|
| + ios::material::TimingFunction(ios::material::CurveEaseInOut);
|
| + CABasicAnimation* scaleAnimation =
|
| + [CABasicAnimation animationWithKeyPath:@"transform"];
|
| + scaleAnimation.fromValue =
|
| + [NSValue valueWithCATransform3D:CATransform3DIdentity];
|
| + scaleAnimation.toValue =
|
| + [NSValue valueWithCATransform3D:CATransform3DMakeScale(
|
| + kSelectionAnimationScale,
|
| + kSelectionAnimationScale, 1)];
|
| + scaleAnimation.timingFunction = timing;
|
| + scaleAnimation.duration = kSelectionAnimationDuration;
|
| + scaleAnimation.fillMode = kCAFillModeForwards;
|
| + scaleAnimation.removedOnCompletion = NO;
|
| + [selectionCircleLayer_ addAnimation:scaleAnimation forKey:@"transform"];
|
| +
|
| + CABasicAnimation* opacityAnimation =
|
| + [CABasicAnimation animationWithKeyPath:@"opacity"];
|
| + opacityAnimation.fromValue = @(selectionCircleLayer_.opacity);
|
| + opacityAnimation.toValue = @(1);
|
| + opacityAnimation.timingFunction = timing;
|
| + opacityAnimation.duration = kSelectionAnimationDuration;
|
| + opacityAnimation.fillMode = kCAFillModeForwards;
|
| + opacityAnimation.removedOnCompletion = NO;
|
| + [selectionCircleLayer_ addAnimation:opacityAnimation forKey:@"opacity"];
|
| +
|
| + CABasicAnimation* positionAnimation =
|
| + [CABasicAnimation animationWithKeyPath:@"position"];
|
| + positionAnimation.fromValue =
|
| + [NSValue valueWithCGPoint:selectionCircleLayer_.position];
|
| +
|
| + CGPoint finalPosition = CGPointMake([self.targetView superview].center.x,
|
| + selectionCircleLayer_.position.y);
|
| + positionAnimation.toValue = [NSValue valueWithCGPoint:finalPosition];
|
| + positionAnimation.timingFunction = timing;
|
| + positionAnimation.duration = kSelectionAnimationDuration;
|
| + positionAnimation.fillMode = kCAFillModeForwards;
|
| + positionAnimation.removedOnCompletion = NO;
|
| + [selectionCircleLayer_ addAnimation:positionAnimation forKey:@"position"];
|
| + [CATransaction commit];
|
| +
|
| + [arrowView_ setAlpha:1];
|
| + [arrowView_ setTintColor:self.backgroundColor];
|
| + [UIView animateWithDuration:kSelectionAnimationDuration
|
| + animations:^{
|
| + [arrowView_ setAlpha:0];
|
| + }];
|
| +}
|
| +
|
| +- (void)handleHorizontalPan:(SideSwipeGestureRecognizer*)gesture
|
| + onOverThresholdCompletion:(void (^)(void))onOverThresholdCompletion
|
| + onUnderThresholdCompletion:(void (^)(void))onUnderThresholdCompletion {
|
| + CGPoint currentPoint = [gesture locationInView:gesture.view];
|
| + CGPoint velocityPoint = [gesture velocityInView:gesture.view];
|
| + currentPoint.x -= gesture.swipeOffset;
|
| +
|
| + // Push point to edge.
|
| + currentPoint = [self adjustPointToEdge:currentPoint];
|
| +
|
| + CGFloat distance = currentPoint.x;
|
| + // The snap back animation is 0.1 seconds, so convert the velocity distance
|
| + // to where the |x| position would in .1 seconds.
|
| + CGFloat velocityOffset = velocityPoint.x * kSwipeVelocityFraction;
|
| + CGFloat width = CGRectGetWidth(self.targetView.bounds);
|
| + if (gesture.direction == UISwipeGestureRecognizerDirectionLeft) {
|
| + distance = width - distance;
|
| + velocityOffset = -velocityOffset;
|
| + }
|
| +
|
| + if (!canNavigate_) {
|
| + // shrink distance a bit to make the drag feel springier.
|
| + distance /= 3;
|
| + }
|
| +
|
| + CGRect frame = self.targetView.frame;
|
| + if (gesture.direction == UISwipeGestureRecognizerDirectionLeft) {
|
| + frame.origin.x = -distance;
|
| + } else {
|
| + frame.origin.x = distance;
|
| + }
|
| + self.targetView.frame = frame;
|
| +
|
| + [self updateFrameAndAnimateContents:distance forGesture:gesture];
|
| +
|
| + if (gesture.state == UIGestureRecognizerStateEnded ||
|
| + gesture.state == UIGestureRecognizerStateCancelled ||
|
| + gesture.state == UIGestureRecognizerStateFailed) {
|
| + CGFloat threshold = width * kSwipeThreshold;
|
| + CGFloat finalDistance = distance + velocityOffset;
|
| + // Ensure the actual distance traveled has met the minimum arrow threshold
|
| + // and that the distance including expected velocity is over |threshold|.
|
| + if (distance > kArrowThreshold && finalDistance > threshold &&
|
| + canNavigate_ && gesture.state == UIGestureRecognizerStateEnded) {
|
| + // Speed up the animation for higher velocity swipes.
|
| + CGFloat animationTime = MapValueToRange(
|
| + {threshold, width},
|
| + {kSelectionAnimationDuration, kSelectionAnimationDuration / 2},
|
| + finalDistance);
|
| + [self animateTargetViewCompleted:YES
|
| + withDirection:gesture.direction
|
| + withDuration:animationTime];
|
| + [self explodeSelection:onOverThresholdCompletion];
|
| + if (IsSwipingForward(gesture.direction)) {
|
| + base::RecordAction(base::UserMetricsAction(
|
| + "MobileEdgeSwipeNavigationForwardCompleted"));
|
| + } else {
|
| + base::RecordAction(
|
| + base::UserMetricsAction("MobileEdgeSwipeNavigationBackCompleted"));
|
| + }
|
| + } else {
|
| + [self animateTargetViewCompleted:NO
|
| + withDirection:gesture.direction
|
| + withDuration:0.1];
|
| + onUnderThresholdCompletion();
|
| + if (IsSwipingForward(gesture.direction)) {
|
| + base::RecordAction(base::UserMetricsAction(
|
| + "MobileEdgeSwipeNavigationForwardCancelled"));
|
| + } else {
|
| + base::RecordAction(
|
| + base::UserMetricsAction("MobileEdgeSwipeNavigationBackCancelled"));
|
| + }
|
| + }
|
| + }
|
| +}
|
| +
|
| +- (void)animateTargetViewCompleted:(BOOL)completed
|
| + withDirection:(UISwipeGestureRecognizerDirection)direction
|
| + withDuration:(CGFloat)duration {
|
| + void (^animationBlock)(void) = ^{
|
| + CGRect targetFrame = self.targetView.frame;
|
| + CGRect frame = self.frame;
|
| + CGFloat width = CGRectGetWidth(self.targetView.bounds);
|
| + // Animate self.targetFrame to the side if completed and to the center if
|
| + // not. Animate self.view to the center if completed or to the size if not.
|
| + if (completed) {
|
| + frame.origin.x = 0;
|
| + frame.size.width = width;
|
| + self.frame = frame;
|
| + targetFrame.origin.x =
|
| + direction == UISwipeGestureRecognizerDirectionRight ? width : -width;
|
| + self.targetView.frame = targetFrame;
|
| + } else {
|
| + targetFrame.origin.x = 0;
|
| + self.targetView.frame = targetFrame;
|
| + frame.origin.x =
|
| + direction == UISwipeGestureRecognizerDirectionLeft ? width : 0;
|
| + frame.size.width = 0;
|
| + self.frame = frame;
|
| + }
|
| + CGRect bounds = self.bounds;
|
| + CGPoint center = CGPointMake(CGRectGetMidX(bounds), CGRectGetMidY(bounds));
|
| + [arrowView_ setCenter:AlignPointToPixel(center)];
|
| + };
|
| + CGFloat cleanUpDelay = completed ? kSelectionAnimationDuration - duration : 0;
|
| + [UIView animateWithDuration:duration
|
| + animations:animationBlock
|
| + completion:^(BOOL finished) {
|
| + // Give the other animations time to complete.
|
| + dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
|
| + cleanUpDelay * NSEC_PER_SEC),
|
| + dispatch_get_main_queue(), ^{
|
| + // Reset target frame.
|
| + CGRect frame = self.targetView.frame;
|
| + frame.origin.x = 0;
|
| + self.targetView.frame = frame;
|
| + [self removeFromSuperview];
|
| + });
|
| + }];
|
| +}
|
| +
|
| +- (CAShapeLayer*)newSelectionCircleLayer {
|
| + const CGRect bounds = CGRectMake(0, 0, kSelectionSize, kSelectionSize);
|
| + CAShapeLayer* selectionCircleLayer = [[CAShapeLayer alloc] init];
|
| + selectionCircleLayer.bounds = bounds;
|
| + selectionCircleLayer.backgroundColor = [[UIColor clearColor] CGColor];
|
| + selectionCircleLayer.fillColor = [[UIColor whiteColor] CGColor];
|
| + selectionCircleLayer.opacity = 0;
|
| + selectionCircleLayer.transform =
|
| + CATransform3DMakeScale(kSelectionDownScale, kSelectionDownScale, 1);
|
| + selectionCircleLayer.path =
|
| + [[UIBezierPath bezierPathWithOvalInRect:bounds] CGPath];
|
| +
|
| + return selectionCircleLayer;
|
| +}
|
| +
|
| +@end
|
|
|