OLD | NEW |
(Empty) | |
| 1 // Copyright 2015 The Chromium Authors. All rights reserved. |
| 2 // Use of this source code is governed by a BSD-style license that can be |
| 3 // found in the LICENSE file. |
| 4 |
| 5 #import "ios/chrome/browser/ui/side_swipe/side_swipe_navigation_view.h" |
| 6 |
| 7 #include <cmath> |
| 8 |
| 9 #include "base/logging.h" |
| 10 #include "base/mac/objc_property_releaser.h" |
| 11 #include "base/mac/scoped_nsobject.h" |
| 12 #include "base/metrics/user_metrics.h" |
| 13 #include "base/metrics/user_metrics_action.h" |
| 14 #import "ios/chrome/browser/ui/side_swipe/side_swipe_util.h" |
| 15 #import "ios/chrome/browser/ui/side_swipe_gesture_recognizer.h" |
| 16 #include "ios/chrome/browser/ui/ui_util.h" |
| 17 #import "ios/chrome/browser/ui/uikit_ui_util.h" |
| 18 #import "ios/chrome/common/material_timing.h" |
| 19 |
| 20 namespace { |
| 21 |
| 22 enum class SwipeType { CHANGE_TABS, NAVIGATION }; |
| 23 |
| 24 typedef struct { |
| 25 CGFloat min; |
| 26 CGFloat max; |
| 27 } FloatRange; |
| 28 |
| 29 CGFloat MapValueToRange(FloatRange from, FloatRange to, CGFloat value) { |
| 30 DCHECK(from.min < from.max); |
| 31 if (value <= from.min) |
| 32 return to.min; |
| 33 if (value >= from.max) |
| 34 return to.max; |
| 35 const CGFloat fromDst = from.max - from.min; |
| 36 const CGFloat toDst = to.max - to.min; |
| 37 return to.min + ((value - from.min) / fromDst) * toDst; |
| 38 } |
| 39 |
| 40 // The portion of the screen width a swipe must travel after which a navigation |
| 41 // should be initiated. |
| 42 const CGFloat kSwipeThreshold = 0.53; |
| 43 |
| 44 // Convert the velocity (which is measured in points per second) to points per |
| 45 // |kSwipeVelocityFraction| of a second. |
| 46 const CGFloat kSwipeVelocityFraction = 0.1; |
| 47 |
| 48 // Distance after which the arrow should animate in. |
| 49 const CGFloat kArrowThreshold = 32; |
| 50 |
| 51 // Duration of the snapping animation when the selection bubble animates. |
| 52 const CGFloat kSelectionSnappingAnimationDuration = 0.2; |
| 53 |
| 54 // Size of the selection circle. |
| 55 const CGFloat kSelectionSize = 64.0; |
| 56 |
| 57 // Start scale of the selection circle. |
| 58 const CGFloat kSelectionDownScale = 0.1875; |
| 59 |
| 60 // The final scale of the selection bubble when the threshold is met. |
| 61 const CGFloat kSelectionAnimationScale = 23; |
| 62 |
| 63 // The duration of the animations played when the threshold is met. |
| 64 const CGFloat kSelectionAnimationDuration = 0.5; |
| 65 } |
| 66 |
| 67 @interface SideSwipeNavigationView () { |
| 68 @private |
| 69 |
| 70 // The back or forward sprite image. |
| 71 base::scoped_nsobject<UIImageView> arrowView_; |
| 72 |
| 73 // The selection bubble. |
| 74 CAShapeLayer* selectionCircleLayer_; |
| 75 |
| 76 // If |NO| this is an edge gesture and navigation isn't possible. Don't show |
| 77 // arrows and bubbles and don't allow navigate. |
| 78 BOOL canNavigate_; |
| 79 |
| 80 // If |YES| arrowView_ is directionnal and must be rotated 180 degreed for the |
| 81 // forward panes. |
| 82 BOOL rotateForward_; |
| 83 |
| 84 base::mac::ObjCPropertyReleaser _propertyReleaser_SideSwipeNavigationView; |
| 85 } |
| 86 // Returns a newly allocated and configured selection circle shape. |
| 87 - (CAShapeLayer*)newSelectionCircleLayer; |
| 88 // Pushes the touch towards the edge because it's difficult to touch the very |
| 89 // edge of the screen (touches tend to sit near x ~ 4). |
| 90 - (CGPoint)adjustPointToEdge:(CGPoint)point; |
| 91 @end |
| 92 |
| 93 @implementation SideSwipeNavigationView |
| 94 |
| 95 @synthesize targetView = targetView_; |
| 96 |
| 97 - (instancetype)initWithFrame:(CGRect)frame |
| 98 withDirection:(UISwipeGestureRecognizerDirection)direction |
| 99 canNavigate:(BOOL)canNavigate |
| 100 image:(UIImage*)image |
| 101 rotateForward:(BOOL)rotateForward { |
| 102 self = [super initWithFrame:frame]; |
| 103 if (self) { |
| 104 _propertyReleaser_SideSwipeNavigationView.Init( |
| 105 self, [SideSwipeNavigationView class]); |
| 106 self.backgroundColor = [UIColor colorWithWhite:90.0 / 256 alpha:1.0]; |
| 107 |
| 108 canNavigate_ = canNavigate; |
| 109 rotateForward_ = rotateForward; |
| 110 if (canNavigate) { |
| 111 image = [image imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; |
| 112 const CGRect imageSize = CGRectMake(0, 0, 24, 24); |
| 113 arrowView_.reset([[UIImageView alloc] initWithImage:image]); |
| 114 [arrowView_ setTintColor:[UIColor whiteColor]]; |
| 115 selectionCircleLayer_ = [self newSelectionCircleLayer]; |
| 116 [arrowView_ setFrame:imageSize]; |
| 117 } |
| 118 |
| 119 UIImage* shadowImage = |
| 120 [UIImage imageNamed:@"side_swipe_navigation_content_shadow"]; |
| 121 CGRect borderFrame = |
| 122 CGRectMake(0, 0, shadowImage.size.width, self.frame.size.height); |
| 123 base::scoped_nsobject<UIImageView> border( |
| 124 [[UIImageView alloc] initWithFrame:borderFrame]); |
| 125 [border setImage:shadowImage]; |
| 126 [self addSubview:border]; |
| 127 if (direction == UISwipeGestureRecognizerDirectionRight) { |
| 128 borderFrame.origin.x = frame.size.width - shadowImage.size.width; |
| 129 [border setFrame:borderFrame]; |
| 130 [border setAutoresizingMask:UIViewAutoresizingFlexibleLeftMargin]; |
| 131 } else { |
| 132 [border setTransform:CGAffineTransformMakeRotation(M_PI)]; |
| 133 [border setAutoresizingMask:UIViewAutoresizingFlexibleRightMargin]; |
| 134 } |
| 135 |
| 136 [self.layer addSublayer:selectionCircleLayer_]; |
| 137 [self setClipsToBounds:YES]; |
| 138 [self addSubview:arrowView_]; |
| 139 } |
| 140 return self; |
| 141 } |
| 142 |
| 143 - (CGPoint)adjustPointToEdge:(CGPoint)currentPoint { |
| 144 CGFloat width = CGRectGetWidth(self.targetView.bounds); |
| 145 CGFloat half = floor(width / 2); |
| 146 CGFloat padding = floor(std::abs(currentPoint.x - half) / half); |
| 147 |
| 148 // Push towards the edges. |
| 149 if (currentPoint.x > half) |
| 150 currentPoint.x += padding; |
| 151 else |
| 152 currentPoint.x -= padding; |
| 153 |
| 154 // But don't go past the edges. |
| 155 if (currentPoint.x < 0) |
| 156 currentPoint.x = 0; |
| 157 else if (currentPoint.x > width) |
| 158 currentPoint.x = width; |
| 159 |
| 160 return currentPoint; |
| 161 } |
| 162 |
| 163 - (void)updateFrameAndAnimateContents:(CGFloat)distance |
| 164 forGesture:(SideSwipeGestureRecognizer*)gesture { |
| 165 CGFloat width = CGRectGetWidth(self.targetView.bounds); |
| 166 |
| 167 // Immediately set frame size. |
| 168 CGRect frame = self.frame; |
| 169 if (gesture.direction == UISwipeGestureRecognizerDirectionRight) { |
| 170 frame.size.width = self.targetView.frame.origin.x; |
| 171 frame.origin.x = 0; |
| 172 } else { |
| 173 frame.origin.x = self.targetView.frame.origin.x + width; |
| 174 frame.size.width = width - frame.origin.x; |
| 175 } |
| 176 [self setFrame:frame]; |
| 177 |
| 178 // Move |selectionCircleLayer_| without animations. |
| 179 CGRect bounds = self.bounds; |
| 180 CGPoint center = CGPointMake(CGRectGetMidX(bounds), CGRectGetMidY(bounds)); |
| 181 [arrowView_ setCenter:AlignPointToPixel(center)]; |
| 182 [CATransaction begin]; |
| 183 [CATransaction setDisableActions:YES]; |
| 184 [selectionCircleLayer_ setPosition:center]; |
| 185 [CATransaction commit]; |
| 186 |
| 187 CGFloat rotationStart = -M_PI_2; |
| 188 CGFloat rotationEnd = 0; |
| 189 if (gesture.direction == UISwipeGestureRecognizerDirectionLeft) { |
| 190 if (rotateForward_) { |
| 191 rotationStart = M_PI * 1.5; |
| 192 rotationEnd = M_PI; |
| 193 } else { |
| 194 rotationStart = M_PI * 0.5; |
| 195 rotationEnd = 0; |
| 196 } |
| 197 } |
| 198 CGAffineTransform rotation = CGAffineTransformMakeRotation(MapValueToRange( |
| 199 {0, kArrowThreshold}, {rotationStart, rotationEnd}, distance)); |
| 200 CGFloat scale = MapValueToRange({0, kArrowThreshold}, {0, 1}, distance); |
| 201 [arrowView_ setTransform:CGAffineTransformScale(rotation, scale, scale)]; |
| 202 |
| 203 // Animate selection bubbles dpending on distance. |
| 204 [UIView beginAnimations:@"transform" context:NULL]; |
| 205 [UIView setAnimationDuration:kSelectionSnappingAnimationDuration]; |
| 206 if (distance < (width * kSwipeThreshold)) { |
| 207 // Scale selection down. |
| 208 selectionCircleLayer_.transform = |
| 209 CATransform3DMakeScale(kSelectionDownScale, kSelectionDownScale, 1); |
| 210 selectionCircleLayer_.opacity = 0; |
| 211 [arrowView_ setAlpha:MapValueToRange({0, 64}, {0, 1}, distance)]; |
| 212 [arrowView_ setTintColor:[UIColor whiteColor]]; |
| 213 } else { |
| 214 selectionCircleLayer_.transform = CATransform3DMakeScale(1, 1, 1); |
| 215 selectionCircleLayer_.opacity = 0.75; |
| 216 [arrowView_ setAlpha:1]; |
| 217 [arrowView_ setTintColor:self.backgroundColor]; |
| 218 } |
| 219 [UIView commitAnimations]; |
| 220 } |
| 221 |
| 222 - (void)explodeSelection:(void (^)(void))block { |
| 223 [CATransaction begin]; |
| 224 [CATransaction setCompletionBlock:^{ |
| 225 // Note that the animations below may complete at slightly different times |
| 226 // resulting in frame(s) between animation completion and the transaction's |
| 227 // completion handler that show the original state. To avoid this flicker, |
| 228 // the animations use a fillMode forward and are not removed until the |
| 229 // transaction completion handler is executed. |
| 230 [selectionCircleLayer_ removeAnimationForKey:@"opacity"]; |
| 231 [selectionCircleLayer_ removeAnimationForKey:@"transform"]; |
| 232 [selectionCircleLayer_ setOpacity:0]; |
| 233 [arrowView_ setAlpha:0]; |
| 234 self.backgroundColor = [UIColor whiteColor]; |
| 235 block(); |
| 236 |
| 237 }]; |
| 238 |
| 239 CAMediaTimingFunction* timing = |
| 240 ios::material::TimingFunction(ios::material::CurveEaseInOut); |
| 241 CABasicAnimation* scaleAnimation = |
| 242 [CABasicAnimation animationWithKeyPath:@"transform"]; |
| 243 scaleAnimation.fromValue = |
| 244 [NSValue valueWithCATransform3D:CATransform3DIdentity]; |
| 245 scaleAnimation.toValue = |
| 246 [NSValue valueWithCATransform3D:CATransform3DMakeScale( |
| 247 kSelectionAnimationScale, |
| 248 kSelectionAnimationScale, 1)]; |
| 249 scaleAnimation.timingFunction = timing; |
| 250 scaleAnimation.duration = kSelectionAnimationDuration; |
| 251 scaleAnimation.fillMode = kCAFillModeForwards; |
| 252 scaleAnimation.removedOnCompletion = NO; |
| 253 [selectionCircleLayer_ addAnimation:scaleAnimation forKey:@"transform"]; |
| 254 |
| 255 CABasicAnimation* opacityAnimation = |
| 256 [CABasicAnimation animationWithKeyPath:@"opacity"]; |
| 257 opacityAnimation.fromValue = @(selectionCircleLayer_.opacity); |
| 258 opacityAnimation.toValue = @(1); |
| 259 opacityAnimation.timingFunction = timing; |
| 260 opacityAnimation.duration = kSelectionAnimationDuration; |
| 261 opacityAnimation.fillMode = kCAFillModeForwards; |
| 262 opacityAnimation.removedOnCompletion = NO; |
| 263 [selectionCircleLayer_ addAnimation:opacityAnimation forKey:@"opacity"]; |
| 264 |
| 265 CABasicAnimation* positionAnimation = |
| 266 [CABasicAnimation animationWithKeyPath:@"position"]; |
| 267 positionAnimation.fromValue = |
| 268 [NSValue valueWithCGPoint:selectionCircleLayer_.position]; |
| 269 |
| 270 CGPoint finalPosition = CGPointMake([self.targetView superview].center.x, |
| 271 selectionCircleLayer_.position.y); |
| 272 positionAnimation.toValue = [NSValue valueWithCGPoint:finalPosition]; |
| 273 positionAnimation.timingFunction = timing; |
| 274 positionAnimation.duration = kSelectionAnimationDuration; |
| 275 positionAnimation.fillMode = kCAFillModeForwards; |
| 276 positionAnimation.removedOnCompletion = NO; |
| 277 [selectionCircleLayer_ addAnimation:positionAnimation forKey:@"position"]; |
| 278 [CATransaction commit]; |
| 279 |
| 280 [arrowView_ setAlpha:1]; |
| 281 [arrowView_ setTintColor:self.backgroundColor]; |
| 282 [UIView animateWithDuration:kSelectionAnimationDuration |
| 283 animations:^{ |
| 284 [arrowView_ setAlpha:0]; |
| 285 }]; |
| 286 } |
| 287 |
| 288 - (void)handleHorizontalPan:(SideSwipeGestureRecognizer*)gesture |
| 289 onOverThresholdCompletion:(void (^)(void))onOverThresholdCompletion |
| 290 onUnderThresholdCompletion:(void (^)(void))onUnderThresholdCompletion { |
| 291 CGPoint currentPoint = [gesture locationInView:gesture.view]; |
| 292 CGPoint velocityPoint = [gesture velocityInView:gesture.view]; |
| 293 currentPoint.x -= gesture.swipeOffset; |
| 294 |
| 295 // Push point to edge. |
| 296 currentPoint = [self adjustPointToEdge:currentPoint]; |
| 297 |
| 298 CGFloat distance = currentPoint.x; |
| 299 // The snap back animation is 0.1 seconds, so convert the velocity distance |
| 300 // to where the |x| position would in .1 seconds. |
| 301 CGFloat velocityOffset = velocityPoint.x * kSwipeVelocityFraction; |
| 302 CGFloat width = CGRectGetWidth(self.targetView.bounds); |
| 303 if (gesture.direction == UISwipeGestureRecognizerDirectionLeft) { |
| 304 distance = width - distance; |
| 305 velocityOffset = -velocityOffset; |
| 306 } |
| 307 |
| 308 if (!canNavigate_) { |
| 309 // shrink distance a bit to make the drag feel springier. |
| 310 distance /= 3; |
| 311 } |
| 312 |
| 313 CGRect frame = self.targetView.frame; |
| 314 if (gesture.direction == UISwipeGestureRecognizerDirectionLeft) { |
| 315 frame.origin.x = -distance; |
| 316 } else { |
| 317 frame.origin.x = distance; |
| 318 } |
| 319 self.targetView.frame = frame; |
| 320 |
| 321 [self updateFrameAndAnimateContents:distance forGesture:gesture]; |
| 322 |
| 323 if (gesture.state == UIGestureRecognizerStateEnded || |
| 324 gesture.state == UIGestureRecognizerStateCancelled || |
| 325 gesture.state == UIGestureRecognizerStateFailed) { |
| 326 CGFloat threshold = width * kSwipeThreshold; |
| 327 CGFloat finalDistance = distance + velocityOffset; |
| 328 // Ensure the actual distance traveled has met the minimum arrow threshold |
| 329 // and that the distance including expected velocity is over |threshold|. |
| 330 if (distance > kArrowThreshold && finalDistance > threshold && |
| 331 canNavigate_ && gesture.state == UIGestureRecognizerStateEnded) { |
| 332 // Speed up the animation for higher velocity swipes. |
| 333 CGFloat animationTime = MapValueToRange( |
| 334 {threshold, width}, |
| 335 {kSelectionAnimationDuration, kSelectionAnimationDuration / 2}, |
| 336 finalDistance); |
| 337 [self animateTargetViewCompleted:YES |
| 338 withDirection:gesture.direction |
| 339 withDuration:animationTime]; |
| 340 [self explodeSelection:onOverThresholdCompletion]; |
| 341 if (IsSwipingForward(gesture.direction)) { |
| 342 base::RecordAction(base::UserMetricsAction( |
| 343 "MobileEdgeSwipeNavigationForwardCompleted")); |
| 344 } else { |
| 345 base::RecordAction( |
| 346 base::UserMetricsAction("MobileEdgeSwipeNavigationBackCompleted")); |
| 347 } |
| 348 } else { |
| 349 [self animateTargetViewCompleted:NO |
| 350 withDirection:gesture.direction |
| 351 withDuration:0.1]; |
| 352 onUnderThresholdCompletion(); |
| 353 if (IsSwipingForward(gesture.direction)) { |
| 354 base::RecordAction(base::UserMetricsAction( |
| 355 "MobileEdgeSwipeNavigationForwardCancelled")); |
| 356 } else { |
| 357 base::RecordAction( |
| 358 base::UserMetricsAction("MobileEdgeSwipeNavigationBackCancelled")); |
| 359 } |
| 360 } |
| 361 } |
| 362 } |
| 363 |
| 364 - (void)animateTargetViewCompleted:(BOOL)completed |
| 365 withDirection:(UISwipeGestureRecognizerDirection)direction |
| 366 withDuration:(CGFloat)duration { |
| 367 void (^animationBlock)(void) = ^{ |
| 368 CGRect targetFrame = self.targetView.frame; |
| 369 CGRect frame = self.frame; |
| 370 CGFloat width = CGRectGetWidth(self.targetView.bounds); |
| 371 // Animate self.targetFrame to the side if completed and to the center if |
| 372 // not. Animate self.view to the center if completed or to the size if not. |
| 373 if (completed) { |
| 374 frame.origin.x = 0; |
| 375 frame.size.width = width; |
| 376 self.frame = frame; |
| 377 targetFrame.origin.x = |
| 378 direction == UISwipeGestureRecognizerDirectionRight ? width : -width; |
| 379 self.targetView.frame = targetFrame; |
| 380 } else { |
| 381 targetFrame.origin.x = 0; |
| 382 self.targetView.frame = targetFrame; |
| 383 frame.origin.x = |
| 384 direction == UISwipeGestureRecognizerDirectionLeft ? width : 0; |
| 385 frame.size.width = 0; |
| 386 self.frame = frame; |
| 387 } |
| 388 CGRect bounds = self.bounds; |
| 389 CGPoint center = CGPointMake(CGRectGetMidX(bounds), CGRectGetMidY(bounds)); |
| 390 [arrowView_ setCenter:AlignPointToPixel(center)]; |
| 391 }; |
| 392 CGFloat cleanUpDelay = completed ? kSelectionAnimationDuration - duration : 0; |
| 393 [UIView animateWithDuration:duration |
| 394 animations:animationBlock |
| 395 completion:^(BOOL finished) { |
| 396 // Give the other animations time to complete. |
| 397 dispatch_after(dispatch_time(DISPATCH_TIME_NOW, |
| 398 cleanUpDelay * NSEC_PER_SEC), |
| 399 dispatch_get_main_queue(), ^{ |
| 400 // Reset target frame. |
| 401 CGRect frame = self.targetView.frame; |
| 402 frame.origin.x = 0; |
| 403 self.targetView.frame = frame; |
| 404 [self removeFromSuperview]; |
| 405 }); |
| 406 }]; |
| 407 } |
| 408 |
| 409 - (CAShapeLayer*)newSelectionCircleLayer { |
| 410 const CGRect bounds = CGRectMake(0, 0, kSelectionSize, kSelectionSize); |
| 411 CAShapeLayer* selectionCircleLayer = [[CAShapeLayer alloc] init]; |
| 412 selectionCircleLayer.bounds = bounds; |
| 413 selectionCircleLayer.backgroundColor = [[UIColor clearColor] CGColor]; |
| 414 selectionCircleLayer.fillColor = [[UIColor whiteColor] CGColor]; |
| 415 selectionCircleLayer.opacity = 0; |
| 416 selectionCircleLayer.transform = |
| 417 CATransform3DMakeScale(kSelectionDownScale, kSelectionDownScale, 1); |
| 418 selectionCircleLayer.path = |
| 419 [[UIBezierPath bezierPathWithOvalInRect:bounds] CGPath]; |
| 420 |
| 421 return selectionCircleLayer; |
| 422 } |
| 423 |
| 424 @end |
OLD | NEW |