chromium/ios/chrome/browser/ui/tab_switcher/tab_grid/grid/regular/tabs_closure_animation.mm

// Copyright 2024 The Chromium Authors
// 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/tab_switcher/tab_grid/grid/regular/tabs_closure_animation.h"

#import "base/check.h"
#import "ios/chrome/common/material_timing.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"

namespace {
// Tab closure full animation duration;
constexpr CFTimeInterval kAnimationDuration = 1.3;

// Duration for the opacity disappearing animation.
constexpr CFTimeInterval kDisappearingOpacityDuration =
    kAnimationDuration / 3.0;

// Delay for the start of the grid cell disappearing animation.
constexpr CFTimeInterval kGridCellDisappearingAnimationDelay = 0.0;

// Delay for the start of the disappearing animation of the wipe effect
// animation.
constexpr CFTimeInterval kWipeDisappearingAnimationDelay =
    kDisappearingOpacityDuration - (kAnimationDuration / 10.0);

// Tab closure animation start point.
constexpr CGFloat kAnimationStartPointX = 0.5;
constexpr CGFloat kAnimationStartPointY = 1.0;

// Disapperating animation radius at the start of the animation.
constexpr CGFloat kDisappearinAnimationStartRadius = 0.1;

// Gradient animation radius at the end of the animation.
constexpr CGFloat kColorGradientAnimationEndRadius = 2.5;

// Grid cell disapperating animation radius at the end of the animation.
constexpr CGFloat kGridCellDisappearingAnimationEndRadius = 2.8;

// Disapperating animation radius at the end of the wipe effect animation.
constexpr CGFloat kWipeDisappearingAnimationEndRadius = 1.8;

// Returns an animated disappearing gradient the size of `frame`. Callers are
// expected to add the gradient to the view hierarchy.
CAGradientLayer* GetAnimatedDisappearingGradient(CGRect frame,
                                                 CGFloat end_radius,
                                                 NSTimeInterval duration,
                                                 NSTimeInterval delay) {
  CAGradientLayer* gradient_layer = [CAGradientLayer layer];
  gradient_layer.type = kCAGradientLayerRadial;

  // Start with everything fully opaque and ease disapering while the circle
  // expands.
  gradient_layer.colors = @[
    (id)[UIColor colorWithWhite:1.0 alpha:1.0].CGColor,
    (id)[UIColor colorWithWhite:1.0 alpha:1.0].CGColor,
    (id)[UIColor colorWithWhite:1.0 alpha:1.0].CGColor,
  ];

  // Start already with a small circle. Since, the circle begins fully opaque,
  // there isn't a harsh transition.
  gradient_layer.startPoint =
      CGPointMake(kAnimationStartPointX, kAnimationStartPointY);
  gradient_layer.endPoint =
      CGPointMake(kAnimationStartPointX + kDisappearinAnimationStartRadius,
                  kAnimationStartPointY - kDisappearinAnimationStartRadius);

  CGFloat frame_size = MAX(frame.size.width, frame.size.height);
  gradient_layer.frame =
      CGRectMake(CGPointZero.x, CGPointZero.y, frame_size, frame_size);

  NSTimeInterval start_time = [gradient_layer convertTime:CACurrentMediaTime()
                                                fromLayer:nil];

  // Circle expanding animation. We'll use the end point to expand the circle
  // past the size of the frame so we end up with a fully transparent view at
  // the end.
  CABasicAnimation* expandAnimation =
      [CABasicAnimation animationWithKeyPath:@"endPoint"];
  expandAnimation.duration = duration - delay;
  expandAnimation.beginTime = start_time + delay;
  expandAnimation.fromValue =
      [NSValue valueWithCGPoint:gradient_layer.endPoint];
  expandAnimation.toValue = [NSValue
      valueWithCGPoint:CGPointMake(kAnimationStartPointX + end_radius,
                                   kAnimationStartPointY - end_radius)];

  // Prolong the end state of the animation, so the view continues to be fully
  // transparent.
  expandAnimation.fillMode = kCAFillModeForwards;
  expandAnimation.removedOnCompletion = NO;

  [gradient_layer addAnimation:expandAnimation forKey:@"endPoint"];

  // Opacity animation. The circle shouldn't be fully visible from the start,
  // but come into view while it's expanding.
  CABasicAnimation* opacity_animation =
      [CABasicAnimation animationWithKeyPath:@"colors"];
  // The opacity animation should be shorter than the main one since the circle
  // should be fully transparent before it stops growing.
  opacity_animation.duration = kDisappearingOpacityDuration;
  opacity_animation.beginTime = start_time + delay;
  opacity_animation.fromValue = gradient_layer.colors;
  opacity_animation.toValue = @[
    (id)[UIColor colorWithWhite:1.0 alpha:0.0].CGColor,
    (id)[UIColor colorWithWhite:1.0 alpha:0.0].CGColor,
    (id)[UIColor colorWithWhite:1.0 alpha:0.8].CGColor,
    (id)[UIColor colorWithWhite:1.0 alpha:1.0].CGColor,
    (id)[UIColor colorWithWhite:1.0 alpha:1.0].CGColor,
  ];

  // Prolong the end state of the animation, so the view continues to be
  // transparent.
  opacity_animation.fillMode = kCAFillModeForwards;
  opacity_animation.removedOnCompletion = NO;

  [gradient_layer addAnimation:opacity_animation forKey:@"colors"];

  return gradient_layer;
}

// Returns the animated gradient that creates a "wipe" effect the size of
// `frame`. Callers are expected to add the gradient to the view hierarchy.
CAGradientLayer* GetAnimatedWipeEffect(CGRect frame, NSTimeInterval duration) {
  CAGradientLayer* gradient_layer = [CAGradientLayer layer];
  gradient_layer.type = kCAGradientLayerRadial;
  gradient_layer.colors = @[
    (id)[[UIColor colorNamed:kBlueColor] colorWithAlphaComponent:0.0].CGColor,
    (id)[[UIColor colorNamed:kBlueColor] colorWithAlphaComponent:0.0].CGColor,
    (id)[[UIColor colorNamed:kBlueColor] colorWithAlphaComponent:0.0].CGColor,
  ];

  // The gradient shouldn't be visible at the start.
  gradient_layer.startPoint =
      CGPointMake(kAnimationStartPointX, kAnimationStartPointY);
  gradient_layer.endPoint =
      CGPointMake(kAnimationStartPointX, kAnimationStartPointY);
  CGFloat frame_size = MAX(frame.size.width, frame.size.height);
  gradient_layer.frame =
      CGRectMake(CGPointZero.x, CGPointZero.y, frame_size, frame_size);

  NSTimeInterval startTime = [gradient_layer convertTime:CACurrentMediaTime()
                                               fromLayer:nil];

  // Expand circle animation.  We'll use the end point to expand the circle past
  // the size of the frame so we end up with a fully colored view at the end.
  CABasicAnimation* end_point_animation =
      [CABasicAnimation animationWithKeyPath:@"endPoint"];
  end_point_animation.duration = duration;
  end_point_animation.beginTime = startTime;
  end_point_animation.fromValue =
      [NSValue valueWithCGPoint:gradient_layer.endPoint];
  end_point_animation.toValue = [NSValue
      valueWithCGPoint:CGPointMake(kAnimationStartPointX +
                                       kColorGradientAnimationEndRadius,
                                   kAnimationStartPointY +
                                       kColorGradientAnimationEndRadius)];

  [gradient_layer addAnimation:end_point_animation forKey:@"endPoint"];

  // Opacity animation. The circle shouldn't be fully visible from the start,
  // but come into view while it's expanding.
  CABasicAnimation* colors_animation =
      [CABasicAnimation animationWithKeyPath:@"colors"];
  // The opacity animation should be shorter than the main one since the circle
  // should be fully opaque before it stops growing.
  colors_animation.beginTime = startTime;
  colors_animation.duration = kDisappearingOpacityDuration;
  colors_animation.fromValue = gradient_layer.colors;
  colors_animation.byValue = @[
    (id)[[UIColor colorNamed:kBlueColor] colorWithAlphaComponent:0.1].CGColor,
    (id)[[UIColor colorNamed:kBlueColor] colorWithAlphaComponent:0.05].CGColor,
    (id)[[UIColor colorNamed:kBlueColor] colorWithAlphaComponent:0.0].CGColor,
  ];
  colors_animation.toValue = @[
    (id)[[UIColor colorNamed:kBlueColor] colorWithAlphaComponent:0.7].CGColor,
    (id)[[UIColor colorNamed:kBlueColor] colorWithAlphaComponent:0.35].CGColor,
    (id)[[UIColor colorNamed:kBlueColor] colorWithAlphaComponent:0.0].CGColor,
  ];

  // Prolong the end state of the animation, so the window continues to be blue.
  // In reality, it will be transparent due to `inner_gradient_layer` being
  // applied to `gradient_layer`.
  colors_animation.fillMode = kCAFillModeForwards;
  colors_animation.removedOnCompletion = NO;

  [gradient_layer addAnimation:colors_animation forKey:@"colors"];

  // Add the gradient to animate the disapering of the blue circle shown by
  // `gradient_layer`.
  CAGradientLayer* inner_gradient_layer = GetAnimatedDisappearingGradient(
      frame, kWipeDisappearingAnimationEndRadius, duration,
      kWipeDisappearingAnimationDelay);
  gradient_layer.mask = inner_gradient_layer;

  return gradient_layer;
}
}  // namespace

@implementation TabsClosureAnimation {
  UIView* _window;
  NSArray<UIView*>* _gridCells;
  CAGradientLayer* _gradientLayer;
}

#pragma mark - Public

- (instancetype)initWithWindow:(UIView*)window
                     gridCells:(NSArray<UIView*>*)gridCells {
  self = [super init];
  if (self) {
    _window = window;
    _gridCells = gridCells;
  }
  return self;
}

- (void)animateWithCompletion:(ProceduralBlock)completion {
  CHECK(!_window.userInteractionEnabled);

  [CATransaction begin];
  [CATransaction
      setAnimationTimingFunction:MaterialTimingFunction(MaterialCurveEaseIn)];
  [CATransaction setAnimationDuration:kAnimationDuration];

  __weak TabsClosureAnimation* weakSelf = self;
  [CATransaction setCompletionBlock:^{
    [weakSelf onAnimationCompletedWithCompletionBlock:completion];
  }];

  [self addWipeEffectAnimation];
  [self addGridCellDisapperingAnimation];

  [CATransaction commit];
}

#pragma mark - Private

// Adds the "wipe" effect animation to `window`.
- (void)addWipeEffectAnimation {
  _gradientLayer = GetAnimatedWipeEffect(_window.frame, kAnimationDuration);
  // The grid view is scrollable. The animation should happen on what is visible
  // in the window not in the middle of the grid view which might not even be
  // visible.
  _gradientLayer.position = _window.center;
  [_window.layer addSublayer:_gradientLayer];
}

// Adds the disappering animation to all views in `_gridCells`.
- (void)addGridCellDisapperingAnimation {
  for (UIView* cell : _gridCells) {
    CAGradientLayer* gridCellGradientLayer = GetAnimatedDisappearingGradient(
        _window.frame, kGridCellDisappearingAnimationEndRadius,
        kAnimationDuration, kGridCellDisappearingAnimationDelay);

    // Get position of the cell on the tab grid's coordinate system. The
    // `gridCellGradientLayer` position coordinates are in the cell's coordinate
    // system. As such, we need to adjust the `gridCellGradientLayer`'s position
    // with the position of the cell in its superview, the grid view.
    CGRect cellInTabGrid = [_window convertRect:cell.frame
                                       fromView:cell.superview];
    gridCellGradientLayer.position =
        CGPointMake(_window.center.x - cellInTabGrid.origin.x,
                    _window.center.y - cellInTabGrid.origin.y);
    cell.layer.mask = gridCellGradientLayer;
  }
}

// Cleans up the view hierarchy after the animation has run by removing
// unnecessary layers.
- (void)onAnimationCompletedWithCompletionBlock:(ProceduralBlock)completion {
  // Remove the main gradient layer after the animation has completed.
  [_gradientLayer removeFromSuperlayer];
  _gradientLayer = nil;

  // Remove the gradient layer in each grid cell mask in favor of hiding the
  // view.
  for (UIView* cell : _gridCells) {
    cell.hidden = YES;
    cell.layer.mask = nil;
  }

  if (completion) {
    completion();
  }
}

@end