chromium/ios/chrome/browser/side_swipe/ui_bundled/card_side_swipe_view.mm

// Copyright 2012 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/side_swipe/ui_bundled/card_side_swipe_view.h"

#import <cmath>

#import "base/ios/device_util.h"
#import "base/memory/raw_ptr.h"
#import "base/metrics/user_metrics.h"
#import "base/metrics/user_metrics_action.h"
#import "base/strings/sys_string_conversions.h"
#import "ios/chrome/browser/shared/model/web_state_list/web_state_list.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/snapshots/model/snapshot_tab_helper.h"
#import "ios/chrome/browser/side_swipe/ui_bundled/side_swipe_gesture_recognizer.h"
#import "ios/chrome/browser/side_swipe/ui_bundled/side_swipe_util.h"
#import "ios/chrome/browser/side_swipe/ui_bundled/swipe_view.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/grid/grid_constants.h"
#import "ios/chrome/browser/ui/toolbar/public/side_swipe_toolbar_snapshot_providing.h"
#import "ios/chrome/browser/ui/toolbar/public/toolbar_type.h"
#import "ios/chrome/browser/web/model/page_placeholder_tab_helper.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"
#import "ios/chrome/grit/ios_theme_resources.h"
#import "ios/web/public/web_state.h"
#import "url/gurl.h"

using base::UserMetricsAction;

namespace {
// Spacing between cards.
const CGFloat kCardHorizontalSpacing = 16;

// Corner radius of cards.
const CGFloat kCardCornerRadius = 32;

// Portion of the screen an edge card can be dragged.
const CGFloat kEdgeCardDragPercentage = 0.35;

// Card animation times.
const NSTimeInterval kAnimationDuration = 0.15;

// Reduce size in -smallGreyImage by this factor.
const CGFloat kResizeFactor = 4;
}  // anonymous namespace

@implementation CardSideSwipeView {
  // Constraint defining the top edge of the background.
  NSLayoutConstraint* _backgroundTopConstraint;

  // The direction of the swipe that initiated this horizontal view.
  UISwipeGestureRecognizerDirection _direction;

  // Card views currently displayed.
  SwipeView* _leftCard;
  SwipeView* _rightCard;

  // Most recent touch location.
  CGPoint _currentPoint;

  // WebStateList provided from the initializer.
  raw_ptr<WebStateList> _webStateList;
}

@synthesize delegate = _delegate;
@synthesize topMargin = _topMargin;

- (instancetype)initWithFrame:(CGRect)frame
                    topMargin:(CGFloat)topMargin
                 webStateList:(WebStateList*)webStateList {
  self = [super initWithFrame:frame];
  if (self) {
    _webStateList = webStateList;
    _currentPoint = CGPointZero;
    _topMargin = topMargin;

    UIView* background = [[UIView alloc] initWithFrame:CGRectZero];
    [self addSubview:background];

    [background setTranslatesAutoresizingMaskIntoConstraints:NO];
    CGFloat topInset = self.safeAreaInsets.top;
    _backgroundTopConstraint =
        [[background topAnchor] constraintEqualToAnchor:self.topAnchor
                                               constant:-topInset];
    [NSLayoutConstraint activateConstraints:@[
      [[background rightAnchor] constraintEqualToAnchor:self.rightAnchor],
      [[background leftAnchor] constraintEqualToAnchor:self.leftAnchor],
      _backgroundTopConstraint,
      [[background bottomAnchor] constraintEqualToAnchor:self.bottomAnchor]
    ]];

    background.backgroundColor = [UIColor colorNamed:kGridBackgroundColor];

    _rightCard =
        [[SwipeView alloc] initWithFrame:CGRectZero topMargin:topMargin];
    _rightCard.layer.cornerRadius = kCardCornerRadius;
    _rightCard.layer.masksToBounds = YES;
    _leftCard =
        [[SwipeView alloc] initWithFrame:CGRectZero topMargin:topMargin];
    _leftCard.layer.cornerRadius = kCardCornerRadius;
    _leftCard.layer.masksToBounds = YES;
    [_rightCard setTranslatesAutoresizingMaskIntoConstraints:NO];
    [_leftCard setTranslatesAutoresizingMaskIntoConstraints:NO];
    [self addSubview:_rightCard];
    [self addSubview:_leftCard];
    AddSameConstraints(_rightCard, self);
    AddSameConstraints(_leftCard, self);
  }
  return self;
}

- (void)setTopMargin:(CGFloat)topMargin {
  _topMargin = topMargin;
  _leftCard.topMargin = topMargin;
  _rightCard.topMargin = topMargin;
}

- (void)updateConstraints {
  [super updateConstraints];
  CGFloat topInset = self.safeAreaInsets.top;
  _backgroundTopConstraint.constant = -topInset;
}

- (CGFloat)cardWidth {
  return CGRectGetWidth(self.bounds);
}

// Set up left and right card views depending on current WebState and swipe
// direction.
- (void)updateViewsForDirection:(UISwipeGestureRecognizerDirection)direction {
  _direction = direction;
  int currentIndex = _webStateList->active_index();
  CGFloat offset = UseRTLLayout() ? -1 : 1;
  if (_direction == UISwipeGestureRecognizerDirectionRight) {
    [self setupCard:_rightCard withIndex:currentIndex];
    [self setupCard:_leftCard withIndex:currentIndex - offset];
  } else {
    [self setupCard:_leftCard withIndex:currentIndex];
    [self setupCard:_rightCard withIndex:currentIndex + offset];
  }
}

// Build a `kResizeFactor` sized greyscaled version of `image`.
- (UIImage*)smallGreyImage:(UIImage*)image {
  CGRect smallSize = CGRectMake(0, 0, image.size.width / kResizeFactor,
                                image.size.height / kResizeFactor);
  UIGraphicsImageRendererFormat* format =
      [UIGraphicsImageRendererFormat preferredFormat];
  format.opaque = YES;
  // Using CIFilter here on iOS 5+ might be faster, but it doesn't easily
  // allow for resizing.  At the max size, it's still too slow for side swipe.

  UIGraphicsImageRenderer* renderer =
      [[UIGraphicsImageRenderer alloc] initWithSize:smallSize.size
                                             format:format];

  return [renderer imageWithActions:^(UIGraphicsImageRendererContext* context) {
    UIBezierPath* background = [UIBezierPath bezierPathWithRect:smallSize];
    [UIColor.blackColor set];
    [background fill];

    [image drawInRect:smallSize blendMode:kCGBlendModeLuminosity alpha:1.0];
  }];
}

// Create card view based on `_webStateList`'s index.
- (void)setupCard:(SwipeView*)card withIndex:(int)index {
  if (index < 0 || index >= _webStateList->count()) {
    [card setHidden:YES];
    return;
  }
  [card setHidden:NO];

  web::WebState* webState = _webStateList->GetWebStateAt(index);
  UIImage* topToolbarSnapshot = [self.toolbarSnapshotProvider
      toolbarSideSwipeSnapshotForWebState:webState
                          withToolbarType:ToolbarType::kPrimary];
  [card setTopToolbarImage:topToolbarSnapshot];
  UIImage* bottomToolbarSnapshot = [self.toolbarSnapshotProvider
      toolbarSideSwipeSnapshotForWebState:webState
                          withToolbarType:ToolbarType::kSecondary];
  [card setBottomToolbarImage:bottomToolbarSnapshot];

  __weak CardSideSwipeView* weakSelf = self;
  base::WeakPtr<web::WebState> weakWebState = webState->GetWeakPtr();
  SnapshotTabHelper::FromWebState(webState)->RetrieveColorSnapshot(^(
      UIImage* image) {
    [weakSelf colorSnapshotRetrieved:image card:card weakWebState:weakWebState];
  });
}

// Helper method that is invoked once the color snapshot has been fetched
// for the WebState returned by `weakWebState`. As the fetching is done
// asynchronously, it is possible for the WebState to have been destroyed
// and thus for `webStateGetter` to return nullptr.
- (void)colorSnapshotRetrieved:(UIImage*)image
                          card:(SwipeView*)card
                  weakWebState:(base::WeakPtr<web::WebState>)weakWebState {
  // If the WebState has been destroyed, the card will be dropped, so
  // the image can be dropped.
  web::WebState* webState = weakWebState.get();
  if (!webState) {
    return;
  }

  // Converting snapshotted images to grey takes too much time for single core
  // devices.  Instead, show the colored image for single core devices and the
  // grey image for multi core devices.
  const bool use_color_image =
      ios::device_util::IsSingleCoreDevice() ||
      !PagePlaceholderTabHelper::FromWebState(webState)
           ->will_add_placeholder_for_next_navigation();

  if (use_color_image) {
    [card setImage:image];
    return;
  }

  __weak CardSideSwipeView* weakSelf = self;
  dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0ul),
                 ^{
                   UIImage* greyImage = [weakSelf smallGreyImage:image];
                   if (greyImage) {
                     dispatch_async(dispatch_get_main_queue(), ^{
                       [card setImage:greyImage];
                     });
                   }
                 });
}

// Move cards according to `currentPoint_.x`. Edge cards only drag
// `kEdgeCardDragPercentage` of `bounds`.
- (void)updateCardPositions {
  CGFloat width = [self cardWidth];

  if ([self isEdgeSwipe]) {
    // If an edge card, don't allow the card to be dragged across the screen.
    // Instead, drag across `kEdgeCardDragPercentage` of the screen.
    _rightCard.transform = CGAffineTransformMakeTranslation(
        (_currentPoint.x) * kEdgeCardDragPercentage, 0);
    _leftCard.transform = CGAffineTransformMakeTranslation(
        (_currentPoint.x - width) * kEdgeCardDragPercentage, 0);
  } else {
    CGFloat rightXBuffer = kCardHorizontalSpacing * _currentPoint.x / width;
    CGFloat leftXBuffer = kCardHorizontalSpacing - rightXBuffer;

    _rightCard.transform =
        CGAffineTransformMakeTranslation(_currentPoint.x + rightXBuffer, 0);
    _leftCard.transform = CGAffineTransformMakeTranslation(
        -width + _currentPoint.x - leftXBuffer, 0);
  }
}

// Update layout with new touch event.
- (void)handleHorizontalPan:(SideSwipeGestureRecognizer*)gesture
      actionBeforeTabSwitch:(TabSwipeHandler)actionBeforeTabSwitch {
  _currentPoint = [gesture locationInView:self];
  _currentPoint.x -= gesture.swipeOffset;

  // Since it's difficult to touch the very edge of the screen (touches tend to
  // sit near x ~ 4), push the touch towards the edge.
  CGFloat width = [self cardWidth];
  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;

  [self updateCardPositions];

  if (gesture.state == UIGestureRecognizerStateEnded ||
      gesture.state == UIGestureRecognizerStateCancelled ||
      gesture.state == UIGestureRecognizerStateFailed) {
    [self finishPanWithActionBeforeTabSwitch:actionBeforeTabSwitch];
  }
}

// Returns whether the current card is an edge card based on swipe direction.
- (BOOL)isEdgeSwipe {
  int currentIndex = _webStateList->active_index();
  return (IsSwipingBack(_direction) && currentIndex == 0) ||
         (IsSwipingForward(_direction) &&
          currentIndex == _webStateList->count() - 1);
}

// Update the current WebState and animate the proper card view if the
// `currentPoint_` is past the center of `bounds`.
- (void)finishPanWithActionBeforeTabSwitch:
    (TabSwipeHandler)actionBeforeTabSwitch {
  int currentIndex = _webStateList->active_index();
  // Something happened and now there is not active WebState.  End card side let
  // swipe and BVC show no tabs UI.
  if (currentIndex == WebStateList::kInvalidIndex)
    return [_delegate sideSwipeViewDismissAnimationDidEnd:self];

  CGFloat width = [self cardWidth];
  CGAffineTransform rightTransform, leftTransform;
  SwipeView* dominantCard;
  int destinationWebStateIndex = currentIndex;
  CGFloat offset = UseRTLLayout() ? -1 : 1;
  if (_direction == UISwipeGestureRecognizerDirectionRight) {
    // If swipe is right and `currentPoint_.x` is over the first 1/3, move left.
    if (_currentPoint.x > width / 3.0 && ![self isEdgeSwipe]) {
      rightTransform =
          CGAffineTransformMakeTranslation(width + kCardHorizontalSpacing, 0);
      leftTransform = CGAffineTransformIdentity;
      destinationWebStateIndex = currentIndex - offset;
      dominantCard = _leftCard;
      base::RecordAction(UserMetricsAction("MobileStackSwipeCompleted"));
    } else {
      leftTransform =
          CGAffineTransformMakeTranslation(-width - kCardHorizontalSpacing, 0);
      rightTransform = CGAffineTransformIdentity;
      dominantCard = _rightCard;
      base::RecordAction(UserMetricsAction("MobileStackSwipeCancelled"));
    }
  } else {
    // If swipe is left and `currentPoint_.x` is over the first 1/3, move right.
    if (_currentPoint.x < (width / 3.0) * 2.0 && ![self isEdgeSwipe]) {
      leftTransform =
          CGAffineTransformMakeTranslation(-width - kCardHorizontalSpacing, 0);
      rightTransform = CGAffineTransformIdentity;
      destinationWebStateIndex = currentIndex + offset;
      dominantCard = _rightCard;
      base::RecordAction(UserMetricsAction("MobileStackSwipeCompleted"));
    } else {
      rightTransform =
          CGAffineTransformMakeTranslation(width + kCardHorizontalSpacing, 0);
      leftTransform = CGAffineTransformIdentity;
      dominantCard = _leftCard;
      base::RecordAction(UserMetricsAction("MobileStackSwipeCancelled"));
    }
  }

  if (actionBeforeTabSwitch) {
    actionBeforeTabSwitch(destinationWebStateIndex);
  }

  if (destinationWebStateIndex != currentIndex) {
    // The old webstate is now hidden. The new WebState will be inserted once
    // the animation is complete.
    _webStateList->GetActiveWebState()->WasHidden();
  }

  // Make sure the dominant card animates on top.
  [dominantCard.superview bringSubviewToFront:dominantCard];

  __weak CardSideSwipeView* weakSelf = self;
  [UIView animateWithDuration:kAnimationDuration
      animations:^{
        [weakSelf animatePanWithLeftCardTransform:leftTransform
                               rightCardTransform:rightTransform];
      }
      completion:^(BOOL finished) {
        [weakSelf onAnimatePanComplete:destinationWebStateIndex];
      }];
}

- (void)animatePanWithLeftCardTransform:(CGAffineTransform)leftCardTransform
                     rightCardTransform:(CGAffineTransform)rightCardTransform {
  _leftCard.transform = leftCardTransform;
  _rightCard.transform = rightCardTransform;
}

- (void)onAnimatePanComplete:(int)destinationWebStateIndex {
  [_leftCard setImage:nil];
  [_rightCard setImage:nil];
  [_leftCard setTopToolbarImage:nil];
  [_rightCard setTopToolbarImage:nil];
  [_leftCard setBottomToolbarImage:nil];
  [_rightCard setBottomToolbarImage:nil];
  [_delegate sideSwipeViewDismissAnimationDidEnd:self];
  // Changing the model even when the webstate is the same at the end of
  // the animation allows the UI to recover.  This call must come last,
  // because ActivateWebStateAt triggers behavior that depends on the view
  // hierarchy being reassembled, which happens in
  // sideSwipeViewDismissAnimationDidEnd.
  if (destinationWebStateIndex < _webStateList->count()) {
    // It seems possible that sometimes `destinationWebStateIndex` is bigger
    // than the last tab, probably because tabs were programmatically closed
    // during the swipe. See crbug.com/333961615.
    _webStateList->ActivateWebStateAt(destinationWebStateIndex);
  }
}

@end