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 |