chromium/ios/chrome/browser/overscroll_actions/ui_bundled/overscroll_actions_view.mm

// Copyright 2015 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/overscroll_actions/ui_bundled/overscroll_actions_view.h"

#import <QuartzCore/QuartzCore.h>

#import <numbers>

#import "base/check.h"
#import "base/ios/block_types.h"
#import "base/numerics/math_constants.h"
#import "base/task/sequenced_task_runner.h"
#import "base/time/time.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/shared/ui/symbols/symbols.h"
#import "ios/chrome/browser/shared/ui/util/rtl_geometry.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/ui/content_suggestions/ntp_home_constant.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ios/chrome/grit/ios_branded_strings.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ios/chrome/grit/ios_theme_resources.h"
#import "ui/base/l10n/l10n_util.h"

namespace {

// The size of overscroll symbol images.
const CGFloat kOverScrollSymbolPointSize = 22.;

// Represents a simple min/max range.
typedef struct {
  CGFloat min;
  CGFloat max;
} FloatRange;

// The threshold at which the refresh actions will start to be visible.
const CGFloat kRefreshThreshold = 48.0;
// The threshold at which the actions are fully visible and can be selected.
const CGFloat kFullThreshold = 56.0;
// The size in point of the edges of the action selection circle layer.
const CGFloat kSelectionEdge = 64.0;
// Initial start position in X of the left and right actions from the center.
// Left actions will start at center.x - kActionsStartPositionMarginFromCenter
// Right actions will start at center.x + kActionsStartPositionMarginFromCenter
const CGFloat kActionsStartPositionMarginFromCenter = 80.0;
// Ranges mapping the width of the screen to the margin of the left and right
// actions images from the frame center.
const FloatRange kActionsPositionMarginsFrom = {320.0, 736.0};
const FloatRange kActionsPositionMarginsTo = {100.0, 200.0};
// Horizontal threshold before visual feedback starts. Threshold applied on
// values in between [-1,1], where -1 corresponds to the leftmost action, and 1
// corresponds to the rightmost action.
const CGFloat kDistanceWhereMovementIsIgnored = 0.1;
// Start scale of the action selection circle when no actions are displayed.
const CGFloat kSelectionInitialDownScale = 0.1;
// Start scale of the action selection circle when actions are displayed but
// no action is selected.
const CGFloat kSelectionDownScale = 0.1875;
// The duration of the animations played when the actions are ready to
// be triggered.
const CGFloat kDisplayActionAnimationDuration = 0.5;
// The duration for the fade animation for an individual action label.  If one
// label is being faded out and another is faded in, the total animation
// duration is twice this value.
const CGFloat kActionLabelFadeDuration = 0.1;
// The final scale of the animation played when an action is triggered.
const CGFloat kDisplayActionAnimationScale = 20;
// This controls how much the selection needs to be moved from the action center
// in order to be snapped to the next action.
// This value must stay in the interval [0,1].
const CGFloat kSelectionSnappingOffsetFromCenter = 0.15;
// Duration of the snapping animation moving the selection circle to the
// selected action.
const CGFloat kSelectionSnappingAnimationDuration = 0.2;
// Controls how much the bezier shape's front and back are deformed.
CGFloat KBezierPathFrontDeformation = 5.0;
CGFloat KBezierPathBackDeformation = 2.5;
// Controls the amount of points the bezier path is made of.
int kBezierPathPointCount = 40;
// Value in point to which the action icon frame will be expanded to detect user
// direct touches.
const CGFloat kDirectTouchFrameExpansion = 20;
// The vertical padding between the bottom of the action image view and its
// corresponding label.
const CGFloat kActionLabelVerticalPadding = 25.0;
// The minimum distance between the action labels and the side of the screen.
const CGFloat kActionLabelSidePadding = 15.0;

// This function maps a value from a range to another.
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;
}

// Used to set the X position of a CALayer.
void SetLayerPositionX(CALayer* layer, CGFloat value) {
  CGPoint position = layer.position;
  position.x = value;
  layer.position = position;
}

// Describes the internal state of the OverscrollActionsView.
enum class OverscrollViewState {
  NONE,     // Initial state.
  PREPARE,  // The actions are starting to be displayed.
  READY     // Actions are fully displayed.
};

}  // namespace

// The brightness of the actions view background color for non incognito mode.
const CGFloat kActionViewBackgroundColorBrightnessNonIncognito = 242.0 / 256.0;
// The brightness of the actions view background color for incognito mode.
const CGFloat kActionViewBackgroundColorBrightnessIncognito = 80.0 / 256.0;

@interface OverscrollActionsView ()<UIGestureRecognizerDelegate> {
  // True when the first layout has been done.
  BOOL _initialLayoutDone;
  // True when an action trigger animation is currently playing.
  BOOL _animatingActionTrigger;
  // Whether the selection circle is deformed.
  BOOL _deformationBehaviorEnabled;
  // Whether the view already made the transition to the READY state at least
  // once.
  BOOL _didTransitionToReadyState;
  // True if the view is directly touched.
  BOOL _viewTouched;
  // An additionnal offset added to the horizontalOffset value in order to take
  // into account snapping.
  CGFloat _snappingOffset;
  // The offset of the currently snapped action.
  CGFloat _snappedActionOffset;
  // The value of the horizontalOffset when a snap animation has been triggered.
  CGFloat _horizontalOffsetOnAnimationStart;
  // The last vertical offset.
  CGFloat _lastVerticalOffset;
  // Last recorded pull start absolute time.
  base::TimeTicks _pullStartTime;
  // Tap gesture recognizer that allow the user to tap on an action to activate
  // it.
  UITapGestureRecognizer* _tapGesture;
  // Array of layers that will be centered vertically.
  // The array is built the first time the method -layersToCenterVertically is
  // called.
  NSArray* _layersToCenterVertically;
}

// Redefined to readwrite.
@property(nonatomic, assign, readwrite) OverscrollAction selectedAction;

// Actions image views.
@property(nonatomic, strong) UIImageView* addTabActionImageView;
@property(nonatomic, strong) UIImageView* reloadActionImageView;
@property(nonatomic, strong) UIImageView* closeTabActionImageView;

// Action labels.
@property(nonatomic, strong) UILabel* addTabLabel;
@property(nonatomic, strong) UILabel* reloadLabel;
@property(nonatomic, strong) UILabel* closeTabLabel;

// The layer displaying the selection circle.
@property(nonatomic, strong) CAShapeLayer* selectionCircleLayer;

// The current vertical offset.
@property(nonatomic, assign) CGFloat verticalOffset;
// The current horizontal offset.
@property(nonatomic, assign) CGFloat horizontalOffset;
// The internal state of the OverscrollActionsView.
@property(nonatomic, assign) OverscrollViewState overscrollState;
// Redefined to readwrite.
@property(nonatomic, strong, readwrite) UIView* backgroundView;
// Snapshot view added on top of the background image view.
@property(nonatomic, strong, readwrite) UIView* snapshotView;
// The parent layer on the selection circle used for cropping purpose.
@property(nonatomic, strong, readwrite) CALayer* selectionCircleCroppingLayer;
// Computed property for whether the current state is incognito or not.
@property(nonatomic, assign, readonly) BOOL incognito;

// An absolute horizontal offset that also takes into account snapping.
- (CGFloat)absoluteHorizontalOffset;
// Computes the margin of the actions image views using the screen's width.
- (CGFloat)actionsPositionMarginFromCenter;
// Performs the layout of the actions image views.
- (void)layoutActions;
// Performs the layout of the action labels.
- (void)layoutActionLabels;
// Absorbs the horizontal movement around the actions in intervals defined with
// kDistanceWhereMovementIsIgnored.
- (CGFloat)absorbsHorizontalMovementAroundActions:(CGFloat)x;
// Computes the position of the selection circle layer based on the horizontal
// offset.
- (CGPoint)selectionCirclePosition;
// Performs layout of the selection circle layer.
- (void)layoutSelectionCircle;
// Updates the selected action depending on the current internal state and
// and the horizontal offset.
- (void)updateSelectedAction;
// Called when the selected action changes in order to perform animations that
// depend on the currently selected action.
- (void)onSelectedActionChangedFromAction:(OverscrollAction)previousAction;
// Layout method used to center subviews vertically.
- (void)centerSubviewsVertically;
// Updates the current internal state of the OverscrollActionsView depending
// on vertical offset.
- (void)updateState;
// Called when the state changes in order to perform state dependent animations.
- (void)onStateChange;
// Resets values related to selection state.
- (void)resetSelection;
// Returns a newly allocated and configured selection circle shape.
- (CAShapeLayer*)newSelectionCircleLayer;
// Returns an autoreleased circular bezier path horizontally deformed according
// to `dx`.
- (UIBezierPath*)circlePath:(CGFloat)dx;
// Returns the action at the given location in the view.
- (OverscrollAction)actionAtLocation:(CGPoint)location;
// Update the selection circle frame to select the given action.
- (void)updateSelectionForTouchedAction:(OverscrollAction)action;
// Clear the direct touch interaction after a small delay to prevent graphic
// glitch with pan gesture selection deformation animations.
- (void)clearDirectTouchInteraction;
// Returns the tooltip label for `action`.
- (UILabel*)labelForAction:(OverscrollAction)action;
// Fades out `previousLabel` and fades in `actionLabel`.
- (void)fadeInActionLabel:(UILabel*)actionLabel
      previousActionLabel:(UILabel*)previousLabel;
@end

@implementation OverscrollActionsView

- (instancetype)initWithFrame:(CGRect)frame {
  self = [super initWithFrame:frame];
  if (self) {
    _deformationBehaviorEnabled = YES;
    self.autoresizingMask =
        UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
    self.clipsToBounds = YES;
    _selectionCircleLayer = [self newSelectionCircleLayer];
    _selectionCircleCroppingLayer = [[CALayer alloc] init];
    _selectionCircleCroppingLayer.frame = self.bounds;
    [_selectionCircleCroppingLayer setMasksToBounds:YES];

    [self.layer addSublayer:_selectionCircleCroppingLayer];
    [_selectionCircleCroppingLayer addSublayer:_selectionCircleLayer];

    _addTabActionImageView = [[UIImageView alloc] init];
    _addTabActionImageView.image = DefaultSymbolTemplateWithPointSize(
        kPlusSymbol, kOverScrollSymbolPointSize);
    _addTabActionImageView.tintColor = [UIColor colorNamed:kTextPrimaryColor];
    if (!IsHomeMemoryImprovementsEnabled()) {
      [_addTabActionImageView sizeToFit];
    }
    [self addSubview:_addTabActionImageView];
    if (IsHomeMemoryImprovementsEnabled()) {
      [_addTabActionImageView sizeToFit];
    }

    _reloadActionImageView = [[UIImageView alloc] init];
    _reloadActionImageView.image = CustomSymbolTemplateWithPointSize(
        kArrowClockWiseSymbol, kOverScrollSymbolPointSize);
    _reloadActionImageView.tintColor = [UIColor colorNamed:kTextPrimaryColor];
    if (!IsHomeMemoryImprovementsEnabled()) {
      [_reloadActionImageView sizeToFit];
    }
    [self addSubview:_reloadActionImageView];
    if (IsHomeMemoryImprovementsEnabled()) {
      [_reloadActionImageView sizeToFit];
    }

    _closeTabActionImageView = [[UIImageView alloc] init];
    _closeTabActionImageView.image = DefaultSymbolTemplateWithPointSize(
        kXMarkSymbol, kOverScrollSymbolPointSize);
    _closeTabActionImageView.tintColor = [UIColor colorNamed:kTextPrimaryColor];
    if (!IsHomeMemoryImprovementsEnabled()) {
      [_closeTabActionImageView sizeToFit];
    }
    [self addSubview:_closeTabActionImageView];
    if (IsHomeMemoryImprovementsEnabled()) {
      [_closeTabActionImageView sizeToFit];
    }

    _addTabLabel = [[UILabel alloc] init];
    _addTabLabel.numberOfLines = 0;
    _addTabLabel.lineBreakMode = NSLineBreakByWordWrapping;
    _addTabLabel.textAlignment = NSTextAlignmentLeft;
    _addTabLabel.alpha = 0.0;
    _addTabLabel.font =
        [UIFont preferredFontForTextStyle:UIFontTextStyleCaption1];
    _addTabLabel.adjustsFontForContentSizeCategory = NO;
    _addTabLabel.textColor = [UIColor colorNamed:kToolbarButtonColor];
    _addTabLabel.text =
        l10n_util::GetNSString(IDS_IOS_OVERSCROLL_NEW_TAB_LABEL);
    [self addSubview:_addTabLabel];

    _reloadLabel = [[UILabel alloc] init];
    _reloadLabel.numberOfLines = 0;
    _reloadLabel.lineBreakMode = NSLineBreakByWordWrapping;
    _reloadLabel.textAlignment = NSTextAlignmentCenter;
    _reloadLabel.alpha = 0.0;
    _reloadLabel.font =
        [UIFont preferredFontForTextStyle:UIFontTextStyleCaption1];
    _reloadLabel.adjustsFontForContentSizeCategory = NO;
    _reloadLabel.textColor = [UIColor colorNamed:kToolbarButtonColor];
    _reloadLabel.text = l10n_util::GetNSString(IDS_IOS_OVERSCROLL_RELOAD_LABEL);
    [self addSubview:_reloadLabel];

    _closeTabLabel = [[UILabel alloc] init];
    _closeTabLabel.numberOfLines = 0;
    _closeTabLabel.lineBreakMode = NSLineBreakByWordWrapping;
    _closeTabLabel.textAlignment = NSTextAlignmentRight;
    _closeTabLabel.alpha = 0.0;
    _closeTabLabel.font =
        [UIFont preferredFontForTextStyle:UIFontTextStyleCaption1];
    _closeTabLabel.adjustsFontForContentSizeCategory = NO;
    _closeTabLabel.textColor = [UIColor colorNamed:kToolbarButtonColor];
    _closeTabLabel.text =
        l10n_util::GetNSString(IDS_IOS_OVERSCROLL_CLOSE_TAB_LABEL);
    [self addSubview:_closeTabLabel];

    _backgroundView = [[UIView alloc] initWithFrame:CGRectZero];
    [self addSubview:_backgroundView];

    if (UseRTLLayout()) {
      // Handle RTL using transforms since this class is CALayer-based.
      [self setTransform:CGAffineTransformMakeScale(-1, 1)];
      // Reverse labels again because they are subview of `self`, otherwise they
      // will be rendered backwards.
      [_addTabLabel setTransform:CGAffineTransformMakeScale(-1, 1)];
      [_reloadLabel setTransform:CGAffineTransformMakeScale(-1, 1)];
      [_closeTabLabel setTransform:CGAffineTransformMakeScale(-1, 1)];
    }

    _tapGesture =
        [[UITapGestureRecognizer alloc] initWithTarget:self
                                                action:@selector(tapGesture:)];
    [_tapGesture setDelegate:self];
    [self addGestureRecognizer:_tapGesture];
  }
  return self;
}

- (void)dealloc {
  [self.snapshotView removeFromSuperview];
}

- (BOOL)selectionCroppingEnabled {
  return [_selectionCircleCroppingLayer masksToBounds];
}

- (void)setSelectionCroppingEnabled:(BOOL)enableSelectionCropping {
  [_selectionCircleCroppingLayer setMasksToBounds:enableSelectionCropping];
}

- (void)addSnapshotView:(UIView*)view {
  if (UseRTLLayout()) {
    [CATransaction begin];
    [CATransaction setDisableActions:YES];
    [view setTransform:CGAffineTransformMakeScale(-1, 1)];
    [CATransaction commit];
  }
  [self.snapshotView removeFromSuperview];
  self.snapshotView = view;
  [self.backgroundView addSubview:self.snapshotView];
}

- (void)pullStarted {
  _didTransitionToReadyState = NO;
  _pullStartTime = base::TimeTicks::Now();
  // Ensure we will update the state after time threshold even without offset
  // change.
  __weak OverscrollActionsView* weakSelf = self;
  base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
      FROM_HERE, base::BindOnce(^{
        [weakSelf updateState];
      }),
      kMinimumPullDurationToTransitionToReady + base::Milliseconds(10));
}

- (void)updateWithVerticalOffset:(CGFloat)offset {
  _lastVerticalOffset = self.verticalOffset;
  self.verticalOffset = offset;
  [self updateState];
}

- (void)updateWithHorizontalOffset:(CGFloat)offset {
  if (_animatingActionTrigger || _viewTouched)
    return;
  self.horizontalOffset = offset;
  // Absorb out of range offset values so that the user doesn't need to
  // compensate in order to move the cursor in the other direction.
  if ([self absoluteHorizontalOffset] < -1)
    _snappingOffset = -self.horizontalOffset - 1;
  if ([self absoluteHorizontalOffset] > 1)
    _snappingOffset = 1 - self.horizontalOffset;
  [self setNeedsLayout];
}

- (void)displayActionAnimation {
  __weak OverscrollActionsView* weakSelf = self;
  _animatingActionTrigger = YES;
  [CATransaction begin];
  [CATransaction setCompletionBlock:^{
    [weakSelf completionForDisplayActionAnimation];
  }];

  CABasicAnimation* scaleAnimation =
      [CABasicAnimation animationWithKeyPath:@"transform"];
  scaleAnimation.fromValue =
      [NSValue valueWithCATransform3D:CATransform3DIdentity];
  scaleAnimation.toValue =
      [NSValue valueWithCATransform3D:CATransform3DMakeScale(
                                          kDisplayActionAnimationScale,
                                          kDisplayActionAnimationScale, 1)];
  scaleAnimation.duration = kDisplayActionAnimationDuration;
  [self.selectionCircleLayer addAnimation:scaleAnimation forKey:@"transform"];

  CABasicAnimation* opacityAnimation =
      [CABasicAnimation animationWithKeyPath:@"opacity"];
  opacityAnimation.fromValue = @(1);
  opacityAnimation.toValue = @(0);
  opacityAnimation.duration = kDisplayActionAnimationDuration;
  // A fillMode forward and manual removal of the animation is needed because
  // the completion handler can be called one frame earlier for the first
  // animation (transform) causing the opacity animation to be removed and show
  // an opacity of 1 for one or two frames.
  opacityAnimation.fillMode = kCAFillModeForwards;
  opacityAnimation.removedOnCompletion = NO;
  [self.selectionCircleLayer addAnimation:opacityAnimation forKey:@"opacity"];

  [CATransaction commit];
}

- (void)completionForDisplayActionAnimation {
  _animatingActionTrigger = NO;
  [CATransaction begin];
  [CATransaction setDisableActions:YES];
  // See comment below for why we manually set opacity to 0 and remove
  // the animation.
  self.selectionCircleLayer.opacity = 0;
  [self.selectionCircleLayer removeAnimationForKey:@"opacity"];
  [self onStateChange];
  [CATransaction commit];
}

- (void)layoutSubviews {
  [super layoutSubviews];

  [CATransaction begin];
  [CATransaction setDisableActions:YES];
  if (self.snapshotView)
    self.backgroundView.frame = self.snapshotView.bounds;
  _selectionCircleCroppingLayer.frame = self.bounds;

  [CATransaction commit];

  const BOOL disableActionsOnInitialLayout =
      !CGRectEqualToRect(CGRectZero, self.frame) && !_initialLayoutDone;
  if (disableActionsOnInitialLayout) {
    [CATransaction begin];
    [CATransaction setDisableActions:YES];
    _initialLayoutDone = YES;
  }
  [self centerSubviewsVertically];
  [self layoutActions];
  [self layoutActionLabels];
  if (_deformationBehaviorEnabled)
    [self layoutSelectionCircleWithDeformation];
  else
    [self layoutSelectionCircle];
  [self updateSelectedAction];
  if (disableActionsOnInitialLayout)
    [CATransaction commit];
}

- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection {
  [super traitCollectionDidChange:previousTraitCollection];
  [self updateLayerColors];
}

#pragma mark - Private

- (CGFloat)absoluteHorizontalOffset {
  return self.horizontalOffset + _snappingOffset;
}

- (CGFloat)actionsPositionMarginFromCenter {
  return MapValueToRange(kActionsPositionMarginsFrom, kActionsPositionMarginsTo,
                         self.bounds.size.width);
}

- (void)layoutActions {
  const CGFloat width = self.bounds.size.width;
  const CGFloat centerX = width / 2.0;
  const CGFloat actionsPositionMargin = [self actionsPositionMarginFromCenter];

  [UIView
      animateWithDuration:0.1
               animations:^{
                 SetLayerPositionX(self.reloadActionImageView.layer, centerX);

                 const CGFloat addTabPositionX = MapValueToRange(
                     {kRefreshThreshold, kFullThreshold},
                     {centerX - kActionsStartPositionMarginFromCenter,
                      centerX - actionsPositionMargin},
                     self.verticalOffset);
                 SetLayerPositionX(self.addTabActionImageView.layer,
                                   addTabPositionX);

                 const CGFloat closeTabPositionX = MapValueToRange(
                     {kRefreshThreshold, kFullThreshold},
                     {centerX + kActionsStartPositionMarginFromCenter,
                      centerX + actionsPositionMargin},
                     self.verticalOffset);
                 SetLayerPositionX(self.closeTabActionImageView.layer,
                                   closeTabPositionX);
               }
               completion:nil];

  [UIView animateWithDuration:0.1
                   animations:^{
                     self.reloadActionImageView.layer.opacity =
                         MapValueToRange({kFullThreshold / 2.0, kFullThreshold},
                                         {0, 1}, self.verticalOffset);
                     self.addTabActionImageView.layer.opacity =
                         MapValueToRange({kRefreshThreshold, kFullThreshold},
                                         {0, 1}, self.verticalOffset);
                     self.closeTabActionImageView.layer.opacity =
                         MapValueToRange({kRefreshThreshold, kFullThreshold},
                                         {0, 1}, self.verticalOffset);
                   }
                   completion:nil];

  [UIView animateWithDuration:0.1
                   animations:^{
                     CATransform3D rotation = CATransform3DMakeRotation(
                         MapValueToRange({kFullThreshold / 2.0, kFullThreshold},
                                         {-std::numbers::pi_v<float> / 2,
                                          std::numbers::pi_v<float> / 4},
                                         self.verticalOffset),
                         0, 0, 1);
                     self.reloadActionImageView.layer.transform = rotation;
                   }
                   completion:nil];
}

- (void)layoutActionLabels {
  // The text is truncated to be a maximum of half the width of the view.
  CGSize boundingSize = self.bounds.size;
  boundingSize.width /= 2.0;

  // The UILabels in `labels` are laid out according to the location of their
  // corresponding UIImageView in `images`.
  NSArray* labels = @[ self.addTabLabel, self.reloadLabel, self.closeTabLabel ];
  NSArray* images = @[
    self.addTabActionImageView, self.reloadActionImageView,
    self.closeTabActionImageView
  ];

  [labels enumerateObjectsUsingBlock:^(UILabel* label, NSUInteger idx, BOOL*) {
    UIImageView* image = images[idx];
    CGRect frame = CGRectZero;
    frame.size = [label sizeThatFits:boundingSize];
    frame.origin.x = image.center.x - frame.size.width / 2.0;
    frame.origin.x = fmaxf(
        frame.origin.x, CGRectGetMinX(self.bounds) + kActionLabelSidePadding);
    frame.origin.x = fminf(frame.origin.x, CGRectGetMaxX(self.bounds) -
                                               kActionLabelSidePadding -
                                               CGRectGetWidth(frame));
    frame.origin.y = image.center.y + CGRectGetHeight(image.bounds) / 2.0 +
                     kActionLabelVerticalPadding;
    label.frame = frame;
  }];
}

- (CGFloat)absorbsHorizontalMovementAroundActions:(CGFloat)x {
  // The limits of the intervals where x is constant.
  const CGFloat kLeftActionAbsorptionLimit =
      -1 + kDistanceWhereMovementIsIgnored;
  const CGFloat kCenterActionLeftAbsorptionLimit =
      -kDistanceWhereMovementIsIgnored;
  const CGFloat kCenterActionRightAbsorptionLimit =
      kDistanceWhereMovementIsIgnored;
  const CGFloat kRightActionAbsorptionLimit =
      1 - kDistanceWhereMovementIsIgnored;
  if (x < kLeftActionAbsorptionLimit) {
    return -1;
  }
  if (x < kCenterActionLeftAbsorptionLimit) {
    return MapValueToRange(
        {kLeftActionAbsorptionLimit, kCenterActionLeftAbsorptionLimit}, {-1, 0},
        x);
  }
  if (x < kCenterActionRightAbsorptionLimit) {
    return 0;
  }
  if (x < kRightActionAbsorptionLimit) {
    return MapValueToRange(
        {kCenterActionRightAbsorptionLimit, kRightActionAbsorptionLimit},
        {0, 1}, x);
  }
  return 1;
}

- (CGPoint)selectionCirclePosition {
  const CGFloat centerX = self.bounds.size.width / 2.0;
  const CGFloat actionsPositionMargin = [self actionsPositionMarginFromCenter];
  const CGFloat transformedOffset = [self
      absorbsHorizontalMovementAroundActions:[self absoluteHorizontalOffset]];
  return CGPointMake(MapValueToRange({-1, 1}, {centerX - actionsPositionMargin,
                                               centerX + actionsPositionMargin},
                                     transformedOffset),
                     self.bounds.size.height / 2.0);
}

- (void)layoutSelectionCircle {
  if (self.overscrollState == OverscrollViewState::READY) {
    [CATransaction begin];
    [CATransaction setDisableActions:YES];
    self.selectionCircleLayer.position = [self selectionCirclePosition];
    [CATransaction commit];
  }
}

- (void)layoutSelectionCircleWithDeformation {
  if (self.overscrollState == OverscrollViewState::READY) {
    BOOL animate = NO;
    CGFloat snapDistance =
        [self absoluteHorizontalOffset] - _snappedActionOffset;
    // Cancel out deformation for small movements.
    if (fabs(snapDistance) < kDistanceWhereMovementIsIgnored) {
      snapDistance = 0;
    } else {
      snapDistance -= snapDistance > 0 ? kDistanceWhereMovementIsIgnored
                                       : -kDistanceWhereMovementIsIgnored;
    }

    [self.selectionCircleLayer removeAnimationForKey:@"path"];
    self.selectionCircleLayer.path = [self circlePath:snapDistance].CGPath;

    if (fabs(snapDistance) > kSelectionSnappingOffsetFromCenter) {
      animate = YES;
      _snappedActionOffset += (snapDistance < 0 ? -1 : 1);
      _snappingOffset = _snappedActionOffset - self.horizontalOffset;
      _horizontalOffsetOnAnimationStart = self.horizontalOffset;
      const CGFloat finalSnapDistance =
          [self absoluteHorizontalOffset] - _snappedActionOffset;

      UIBezierPath* finalPath = [self circlePath:finalSnapDistance];
      [CATransaction begin];
      [CATransaction setCompletionBlock:^{
        self.selectionCircleLayer.path = finalPath.CGPath;
        [self.selectionCircleLayer removeAnimationForKey:@"path"];
      }];
      CABasicAnimation* (^pathAnimation)(void) = ^{
        CABasicAnimation* pathAnim =
            [CABasicAnimation animationWithKeyPath:@"path"];
        pathAnim.removedOnCompletion = NO;
        pathAnim.fillMode = kCAFillModeForwards;
        pathAnim.duration = kSelectionSnappingAnimationDuration;
        pathAnim.toValue = (__bridge id)finalPath.CGPath;
        return pathAnim;
      };
      [self.selectionCircleLayer addAnimation:pathAnimation() forKey:@"path"];
      [CATransaction commit];
    }
    [CATransaction begin];
    if (!animate)
      [CATransaction setDisableActions:YES];
    else
      [CATransaction setAnimationDuration:kSelectionSnappingAnimationDuration];
    self.selectionCircleLayer.position = [self selectionCirclePosition];
    [CATransaction commit];
  }
}

- (void)updateSelectedAction {
  if (self.overscrollState != OverscrollViewState::READY) {
    self.selectedAction = OverscrollAction::NONE;
    return;
  }

  // Update action index by checking that the action image layer is included
  // inside the selection layer.
  const CGPoint selectionPosition = [self selectionCirclePosition];
  if (_deformationBehaviorEnabled) {
    const CGFloat distanceBetweenTwoActions =
        (self.reloadActionImageView.frame.origin.x -
         self.addTabActionImageView.frame.origin.x) /
        2;
    if (fabs(self.addTabActionImageView.center.x - selectionPosition.x) <
        distanceBetweenTwoActions) {
      self.selectedAction = OverscrollAction::NEW_TAB;
    }
    if (fabs(self.reloadActionImageView.center.x - selectionPosition.x) <
        distanceBetweenTwoActions) {
      self.selectedAction = OverscrollAction::REFRESH;
    }
    if (fabs(self.closeTabActionImageView.center.x - selectionPosition.x) <
        distanceBetweenTwoActions) {
      self.selectedAction = OverscrollAction::CLOSE_TAB;
    }
  } else {
    const CGRect selectionRect =
        CGRectMake(selectionPosition.x - kSelectionEdge / 2.0,
                   selectionPosition.y - kSelectionEdge / 2.0, kSelectionEdge,
                   kSelectionEdge);
    const CGRect addTabRect = self.addTabActionImageView.frame;
    const CGRect closeTabRect = self.closeTabActionImageView.frame;
    const CGRect refreshRect = self.reloadActionImageView.frame;

    if (CGRectContainsRect(selectionRect, addTabRect)) {
      self.selectedAction = OverscrollAction::NEW_TAB;
    } else if (CGRectContainsRect(selectionRect, refreshRect)) {
      self.selectedAction = OverscrollAction::REFRESH;
    } else if (CGRectContainsRect(selectionRect, closeTabRect)) {
      self.selectedAction = OverscrollAction::CLOSE_TAB;
    } else {
      self.selectedAction = OverscrollAction::NONE;
    }
  }
}

- (void)setSelectedAction:(OverscrollAction)action {
  if (_selectedAction != action) {
    OverscrollAction previousAction = _selectedAction;
    _selectedAction = action;
    [self onSelectedActionChangedFromAction:previousAction];
  }
}

- (void)onSelectedActionChangedFromAction:(OverscrollAction)previousAction {
  [self fadeInActionLabel:[self labelForAction:self.selectedAction]
      previousActionLabel:[self labelForAction:previousAction]];

  if (self.overscrollState == OverscrollViewState::PREPARE ||
      _animatingActionTrigger)
    return;

  __weak OverscrollActionsView* weakSelf = self;
  [UIView animateWithDuration:kSelectionSnappingAnimationDuration
                   animations:^{
                     [weakSelf animateSelectedActionChanged];
                   }
                   completion:nil];

  [self.delegate overscrollActionsView:self
               selectedActionDidChange:self.selectedAction];
}

// Animation handler for onSelectedActionChangedFromAction
- (void)animateSelectedActionChanged {
  if (self.selectedAction == OverscrollAction::NONE) {
    if (!_deformationBehaviorEnabled) {
      // Scale selection down.
      self.selectionCircleLayer.transform =
          CATransform3DMakeScale(kSelectionDownScale, kSelectionDownScale, 1);
    }
  } else {
    // Scale selection up.
    self.selectionCircleLayer.transform = CATransform3DMakeScale(1, 1, 1);
  }
}

- (NSArray*)layersToCenterVertically {
  if (!_layersToCenterVertically) {
    _layersToCenterVertically = @[
      _selectionCircleLayer, _addTabActionImageView.layer,
      _reloadActionImageView.layer, _closeTabActionImageView.layer,
      _backgroundView.layer
    ];
  }
  return _layersToCenterVertically;
}

- (void)centerSubviewsVertically {
  [CATransaction begin];
  [CATransaction setDisableActions:YES];
  for (CALayer* layer in self.layersToCenterVertically) {
    CGPoint position = layer.position;
    position.y = self.bounds.size.height / 2;
    layer.position = position;
  }
  [CATransaction commit];
}

- (void)updateState {
  if (self.verticalOffset > 1) {
    const base::TimeDelta elapsedTime =
        base::TimeTicks::Now() - _pullStartTime;
    const BOOL isMinimumTimeElapsed =
        elapsedTime >= kMinimumPullDurationToTransitionToReady;
    const BOOL isPullingDownOrAlreadyTriggeredOnce =
        _lastVerticalOffset <= self.verticalOffset ||
        _didTransitionToReadyState;
    const BOOL isVerticalThresholdSatisfied =
        self.verticalOffset >= kFullThreshold;
    if (isPullingDownOrAlreadyTriggeredOnce && isVerticalThresholdSatisfied &&
        isMinimumTimeElapsed) {
      self.overscrollState = OverscrollViewState::READY;
    } else {
      self.overscrollState = OverscrollViewState::PREPARE;
    }
  } else {
    self.overscrollState = OverscrollViewState::NONE;
  }
  [self setNeedsLayout];
}

- (void)setOverscrollState:(OverscrollViewState)state {
  if (_overscrollState != state) {
    _overscrollState = state;
    [self onStateChange];
  }
}

- (void)onStateChange {
  if (_animatingActionTrigger)
    return;

  if (self.overscrollState != OverscrollViewState::NONE) {
    [UIView animateWithDuration:kSelectionSnappingAnimationDuration
                     animations:^{
                       self.selectionCircleLayer.opacity =
                           self.overscrollState == OverscrollViewState::READY
                               ? 1.0
                               : 0.0;
                     }
                     completion:nil];

    if (self.overscrollState == OverscrollViewState::PREPARE) {
      [UIView animateWithDuration:kSelectionSnappingAnimationDuration
                       animations:^{
                         [self resetSelection];
                       }
                       completion:nil];
    } else {
      _didTransitionToReadyState = YES;
    }
  } else {
    [CATransaction begin];
    [CATransaction setDisableActions:YES];
    [self resetSelection];
    [CATransaction commit];
  }
}

- (void)resetSelection {
  _didTransitionToReadyState = NO;
  _snappingOffset = 0;
  _snappedActionOffset = 0;
  _horizontalOffsetOnAnimationStart = 0;
  self.selectionCircleLayer.transform = CATransform3DMakeScale(
      kSelectionInitialDownScale, kSelectionInitialDownScale, 1);
  [self updateSelectedAction];
}

- (CAShapeLayer*)newSelectionCircleLayer {
  const CGRect bounds = CGRectMake(0, 0, kSelectionEdge, kSelectionEdge);
  CAShapeLayer* selectionCircleLayer = [[CAShapeLayer alloc] init];
  selectionCircleLayer.bounds = bounds;
  selectionCircleLayer.backgroundColor = UIColor.clearColor.CGColor;
  selectionCircleLayer.opacity = 0;
  selectionCircleLayer.transform = CATransform3DMakeScale(
      kSelectionInitialDownScale, kSelectionInitialDownScale, 1);
  selectionCircleLayer.path =
      [[UIBezierPath bezierPathWithOvalInRect:bounds] CGPath];

  return selectionCircleLayer;
}

- (UIBezierPath*)circlePath:(CGFloat)dx {
  UIBezierPath* path = [UIBezierPath bezierPath];

  CGFloat radius = kSelectionEdge * 0.5;
  CGFloat deformationDirection = dx > 0 ? 1 : -1;
  for (int i = 0; i < kBezierPathPointCount; i++) {
    CGPoint p;
    float angle = i * 2 * std::numbers::pi_v<float> / kBezierPathPointCount;

    // Circle centered on 0.
    p.x = cos(angle) * radius;
    p.y = sin(angle) * radius;

    // Horizontal deformation. The further the points are from the center, the
    // larger the deformation is.
    if (p.x * deformationDirection > 0) {
      p.x += p.x * dx * KBezierPathFrontDeformation * deformationDirection;
    } else {
      p.x += p.x * dx * KBezierPathBackDeformation * deformationDirection;
    }

    // Translate center of circle.
    p.x += radius;
    p.y += radius;

    if (i == 0) {
      [path moveToPoint:p];
    } else {
      [path addLineToPoint:p];
    }
  }

  [path closePath];
  return path;
}

- (void)setStyle:(OverscrollStyle)style {
  _style = style;
  switch (self.style) {
    case OverscrollStyle::NTP_NON_INCOGNITO:
      self.backgroundColor = [UIColor clearColor];
      break;
    case OverscrollStyle::NTP_INCOGNITO:
      self.backgroundColor = [UIColor colorWithWhite:0 alpha:0];
      break;
    case OverscrollStyle::REGULAR_PAGE_NON_INCOGNITO:
      self.backgroundColor = [UIColor colorNamed:kBackgroundColor];
      break;
    case OverscrollStyle::REGULAR_PAGE_INCOGNITO:
      self.backgroundColor = [UIColor colorNamed:kBackgroundColor];
      break;
  }

  [self updateLayerColors];
}

// Updates the colors based on the current trait collection. CGColor doesn't
// support iOS 13 dynamic colors, so those must be resolved more often.
- (void)updateLayerColors {
  [self.traitCollection performAsCurrentTraitCollection:^{
    BOOL darkModeEnabled =
        (self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark);
    _selectionCircleLayer.fillColor =
        darkModeEnabled ? [UIColor colorWithWhite:0.7 alpha:0.2].CGColor
                        : [UIColor colorWithWhite:0.3 alpha:0.125].CGColor;
  }];
}

- (OverscrollAction)actionAtLocation:(CGPoint)location {
  OverscrollAction action = OverscrollAction::NONE;
  if (CGRectContainsPoint(
          CGRectInset([_addTabActionImageView frame],
                      -kDirectTouchFrameExpansion, -kDirectTouchFrameExpansion),
          location)) {
    action = OverscrollAction::NEW_TAB;
  } else if (CGRectContainsPoint(CGRectInset([_reloadActionImageView frame],
                                             -kDirectTouchFrameExpansion,
                                             -kDirectTouchFrameExpansion),
                                 location)) {
    action = OverscrollAction::REFRESH;
  } else if (CGRectContainsPoint(CGRectInset([_closeTabActionImageView frame],
                                             -kDirectTouchFrameExpansion,
                                             -kDirectTouchFrameExpansion),
                                 location)) {
    action = OverscrollAction::CLOSE_TAB;
  }
  return action;
}

- (void)updateSelectionForTouchedAction:(OverscrollAction)action {
  switch (action) {
    case OverscrollAction::NEW_TAB:
      [self updateWithHorizontalOffset:-1];
      break;
    case OverscrollAction::REFRESH:
      [self updateWithHorizontalOffset:0];
      break;
    case OverscrollAction::CLOSE_TAB:
      [self updateWithHorizontalOffset:1];
      break;
    case OverscrollAction::NONE:
      return;
  }
}

// Clear the direct touch interaction after a small delay to prevent graphic
// glitch with pan gesture selection deformation animations.
- (void)clearDirectTouchInteraction {
  if (!_viewTouched)
    return;
  __weak OverscrollActionsView* weakSelf = self;
  base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
      FROM_HERE, base::BindOnce(^{
        [weakSelf clearDirectTouchInteractionAfterDelay];
      }),
      base::Milliseconds(100));
}

- (void)clearDirectTouchInteractionAfterDelay {
  _deformationBehaviorEnabled = YES;
  _viewTouched = NO;
}

- (UILabel*)labelForAction:(OverscrollAction)action {
  switch (action) {
    case OverscrollAction::NEW_TAB:
      return self.addTabLabel;
    case OverscrollAction::REFRESH:
      return self.reloadLabel;
    case OverscrollAction::CLOSE_TAB:
      return self.closeTabLabel;
    case OverscrollAction::NONE:
      return nil;
  }
}

- (void)fadeInActionLabel:(UILabel*)actionLabel
      previousActionLabel:(UILabel*)previousLabel {
  NSUInteger labelCount = (actionLabel ? 1 : 0) + (previousLabel ? 1 : 0);
  if (!labelCount)
    return;

  NSTimeInterval duration = labelCount * kActionLabelFadeDuration;
  NSTimeInterval relativeDuration = 1.0 / labelCount;
  UIViewKeyframeAnimationOptions options =
      UIViewKeyframeAnimationOptionBeginFromCurrentState;
  ProceduralBlock animations = ^{
    CGFloat startTime = 0.0;
    if (previousLabel) {
      [UIView addKeyframeWithRelativeStartTime:startTime
                              relativeDuration:relativeDuration
                                    animations:^{
                                      previousLabel.alpha = 0.0;
                                    }];
      startTime += relativeDuration;
    }
    if (actionLabel) {
      [UIView addKeyframeWithRelativeStartTime:startTime
                              relativeDuration:relativeDuration
                                    animations:^{
                                      actionLabel.alpha = 1.0;
                                    }];
    }
  };
  [UIView animateKeyframesWithDuration:duration
                                 delay:0
                               options:options
                            animations:animations
                            completion:nil];
}

#pragma mark - UIResponder

- (void)touchesBegan:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event {
  [super touchesBegan:touches withEvent:event];
  if (_viewTouched)
    return;

  _deformationBehaviorEnabled = NO;
  _snappingOffset = 0;
  CGPoint tapLocation = [[touches anyObject] locationInView:self];
  [self updateSelectionForTouchedAction:[self actionAtLocation:tapLocation]];
  [self layoutSubviews];
  _viewTouched = YES;
}

- (void)touchesCancelled:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event {
  [super touchesCancelled:touches withEvent:event];
  [self clearDirectTouchInteraction];
}

- (void)touchesEnded:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event {
  [super touchesEnded:touches withEvent:event];
  [self clearDirectTouchInteraction];
}

#pragma mark - UIGestureRecognizerDelegate

- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer
    shouldRecognizeSimultaneouslyWithGestureRecognizer:
        (UIGestureRecognizer*)otherGestureRecognizer {
  return YES;
}

#pragma mark - Tap gesture action

- (void)tapGesture:(UITapGestureRecognizer*)tapRecognizer {
  CGPoint tapLocation = [tapRecognizer locationInView:self];
  OverscrollAction action = [self actionAtLocation:tapLocation];
  if (action != OverscrollAction::NONE) {
    [self updateSelectionForTouchedAction:action];
    [self setSelectedAction:action];
    [self.delegate overscrollActionsViewDidTapTriggerAction:self];
  }
}

@end