// Copyright 2018 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/transitions/legacy_grid_transition_animation.h"
#import "ios/chrome/browser/shared/ui/util/property_animator_group.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/transitions/legacy_grid_to_tab_transition_view.h"
#import "ios/chrome/browser/ui/tab_switcher/tab_grid/transitions/legacy_grid_transition_layout.h"
namespace {
// Scale factor for inactive items when a tab is expanded.
const CGFloat kInactiveItemScale = 0.95;
// Minimum resize ratio used to calculate the resizeDamping correction.
const CGFloat kMinResizeRatio = 0.70;
// Resize ratio multiplier used to calculate the resizeDamping correction.
const CGFloat kResizeRatioMultiplier = 0.37;
// To visually match the active cell's damping effect during scale animation,
// the resizeDamping value should not be static. It should be adjusted based on
// the device's screen size which directly affects the active item's resize
// ratio.
//
// To put it simple: the larger screen user has the bigger amplitude damping
// will be during scale animation. This happens due to unequal scaling ratios
// on the different screen sizes while resizing the tab view to a tab grid
// cell (and vice versa). The tab view size basically equals the device's
// screen size, whether the tab grid cells have the same size across the
// different devices.
//
// For example, the tab view to a regular tab grid cell scale animation on the
// iPhone SE will have a scale ratio of 0.30. Whether, the same scale ratio
// on the iPad Pro would be only 0.19. This means that on the iPad the same
// animation will dampen more with the same resizeDamping value.
//
// The issue is getting much more visible when scaling from the tab view to a
// pinned tab grid cell. In this case the scale ratio is only 0.03! This is 10
// times less compared to the regular tab grid cell on the iPhone SE.
//
// Therefore, the resizeDamping value should be corrected. But on the other
// hand, changing the resizeDamping value for only 0.1 has a huge effect on its
// amplitude. This means that the correction itself should be minimal.
//
// To calculate the correction we'll take known scales ratios as the boundaries
// for the calculation. E.g. minimum = 0.03 (pinned cell on iPad),
// maximum = 0.30 (regular cell on iPhone SE). The lower scale ratio is the
// higher resizeDamping should be. It would be easier to operate the inverted
// values: (1 - 0.30) = 0.7 as a minimum possible value and (1 - 0.03) = 0.97
// as a maximum possible value.
//
// To achieve 0.1 resizeDamping correction the value should be multiplied by
// 0.37. This comes from:
// max_correction = (max - min) * multiplier
// multiplier = max_correction / (max - min)
// multiplier = 0.1 / (0.97 - 0.7) = 0.1 / 0.27 = 0.37
//
// Based on the above, the correction formula should be:
// correction = ((1 - value) - 0.7) * 0.37
//
CGFloat CalculateResizeDampingCorrection(LegacyGridTransitionLayout* layout) {
CGFloat resizeRatio = CGRectGetHeight(layout.activeItem.cell.frame) /
CGRectGetHeight(layout.expandedRect);
return ((1 - resizeRatio) - kMinResizeRatio) * kResizeRatioMultiplier;
}
} // namespace
@interface LegacyGridTransitionAnimation ()
// The property animator group backing the public `animator` property.
@property(nonatomic, readonly) PropertyAnimatorGroup* animations;
// The layout of the grid for this animation.
@property(nonatomic, strong) LegacyGridTransitionLayout* layout;
// The duration of the animation.
@property(nonatomic, readonly, assign) NSTimeInterval duration;
// The direction this animation is in.
@property(nonatomic, readonly, assign) GridAnimationDirection direction;
// Corner radius that the active cell will have when it is animated into the
// regulat grid.
@property(nonatomic, assign) CGFloat finalActiveCellCornerRadius;
// The resize damping correction for the current layout.
@property(nonatomic, assign) CGFloat resizeDampingCorrection;
@end
@implementation LegacyGridTransitionAnimation {
// The frame of the container for the grid cells.
CGRect _gridContainerFrame;
}
- (instancetype)initWithLayout:(LegacyGridTransitionLayout*)layout
gridContainerFrame:(CGRect)gridContainerFrame
duration:(NSTimeInterval)duration
direction:(GridAnimationDirection)direction {
if ((self = [super initWithFrame:CGRectZero])) {
_animations = [[PropertyAnimatorGroup alloc] init];
_gridContainerFrame = gridContainerFrame;
_layout = layout;
_duration = duration;
_direction = direction;
_finalActiveCellCornerRadius = _layout.activeItem.cell.cornerRadius;
_resizeDampingCorrection = CalculateResizeDampingCorrection(layout);
}
return self;
}
- (id<UIViewImplicitlyAnimating>)animator {
return self.animations;
}
- (UIView*)activeItem {
return self.layout.activeItem.cell;
}
- (UIView*)selectionItem {
return self.layout.selectionItem.cell;
}
#pragma mark - UIView
- (void)willMoveToSuperview:(UIView*)newSuperview {
self.frame = newSuperview.bounds;
if (newSuperview && self.subviews.count == 0) {
[self prepareForAnimationInSuperview:newSuperview];
}
}
- (void)didMoveToSuperview {
if (!self.superview) {
return;
}
[self prepareForTransition];
// Positioning the animating items depends on converting points to this
// view's coordinate system, so wait until it's in a view hierarchy.
switch (self.direction) {
case GridAnimationDirectionContracting:
[self positionExpandedActiveItem];
[self prepareInactiveItemsForAppearance];
[self buildContractingAnimations];
break;
case GridAnimationDirectionExpanding:
[self prepareAllItemsForExpansion];
[self buildExpandingAnimations];
break;
}
// Make sure all of the layout after the view setup is complete before any
// animations are run.
[self layoutIfNeeded];
}
#pragma mark - Private methods
- (void)buildContractingAnimations {
// The transition is structured as three or five separate animations. They are
// timed based on various sub-durations and delays which are expressed as
// fractions of the overall animation duration.
CGFloat partialDuration = 0.6;
CGFloat briefDuration = partialDuration * 0.5;
CGFloat shortDelay = 0.2;
// Damping ratio for the resize animation.
CGFloat resizeDamping = 0.8 + _resizeDampingCorrection;
// If there's only one cell, the animation has two parts.
// (A) Zooming the active cell into position.
// (B) Crossfading from the tab to cell top view.
// (C) Rounding the corners of the active cell.
//
// {0%}----------------------[A]-------------------{100%}
// {0%}----------------------[B]-------------{80%}
// {0%}---[C]---{30%}
// If there's more than once cell, the animation adds two more parts:
// (D) Scaling up the inactive cells.
// (E) Fading the inactive cells to 100% opacity.
// The overall timing is as follows:
//
// {0%}----------------------[A]-------------------{100%}
// {0%}----------------------[B]-------------{80%}
// {0%}---[C]---{30%}
// {20%}--[D]-----------------------------{100%}
// {20%}--[E]-----------------------{80%}
//
// (Changing the timing constants above will change the timing % values)
UIView<LegacyGridToTabTransitionView>* activeCell =
self.layout.activeItem.cell;
// The final cell snapshot exactly matches the main tab view of the cell, so
// it can have an alpha of 0 for the whole animation.
activeCell.mainTabView.alpha = 0.0;
// The final cell header starts at 0 alpha and is cross-faded in.
activeCell.topCellView.alpha = 0.0;
// A: Zoom the active cell into position.
auto zoomActiveCellAnimation = ^{
[self positionAndScaleActiveItemInGrid];
};
UIViewPropertyAnimator* zoomActiveCell =
[[UIViewPropertyAnimator alloc] initWithDuration:self.duration
dampingRatio:resizeDamping
animations:zoomActiveCellAnimation];
[self.animations addAnimator:zoomActiveCell];
// B: Fade in the active cell top cell view, fade out the active cell's
// top tab view.
auto fadeInAuxillaryKeyframeAnimation =
[self keyframeAnimationFadingView:activeCell.topTabView
throughToView:activeCell.topCellView
relativeDuration:briefDuration];
UIViewPropertyAnimator* fadeInAuxillary = [[UIViewPropertyAnimator alloc]
initWithDuration:self.duration
curve:UIViewAnimationCurveEaseInOut
animations:fadeInAuxillaryKeyframeAnimation];
[self.animations addAnimator:fadeInAuxillary];
// C: Round the corners of the active cell.
UIView<LegacyGridToTabTransitionView>* cell = self.layout.activeItem.cell;
cell.cornerRadius = DeviceCornerRadius();
auto roundCornersAnimation = ^{
cell.cornerRadius = self.finalActiveCellCornerRadius;
};
auto roundCornersKeyframeAnimation =
[self keyframeAnimationWithRelativeStart:0
relativeDuration:briefDuration
animations:roundCornersAnimation];
UIViewPropertyAnimator* roundCorners = [[UIViewPropertyAnimator alloc]
initWithDuration:self.duration
curve:UIViewAnimationCurveLinear
animations:roundCornersKeyframeAnimation];
[self.animations addAnimator:roundCorners];
// Single cell case.
if (self.layout.inactiveItems.count == 0) {
return;
}
// Additional animations for multiple cells.
// D: Scale up inactive cells.
auto scaleUpCellsAnimation = ^{
for (LegacyGridTransitionItem* item in self.layout.inactiveItems) {
item.cell.transform = CGAffineTransformIdentity;
}
};
auto scaleUpCellsKeyframeAnimation =
[self keyframeAnimationWithRelativeStart:shortDelay
relativeDuration:1 - shortDelay
animations:scaleUpCellsAnimation];
UIViewPropertyAnimator* scaleUpCells = [[UIViewPropertyAnimator alloc]
initWithDuration:self.duration
curve:UIViewAnimationCurveEaseOut
animations:scaleUpCellsKeyframeAnimation];
[self.animations addAnimator:scaleUpCells];
// E: Fade in inactive cells.
auto fadeInCellsAnimation = ^{
for (LegacyGridTransitionItem* item in self.layout.inactiveItems) {
item.cell.alpha = 1.0;
}
};
auto fadeInCellsKeyframeAnimation =
[self keyframeAnimationWithRelativeStart:shortDelay
relativeDuration:partialDuration
animations:fadeInCellsAnimation];
UIViewPropertyAnimator* fadeInCells = [[UIViewPropertyAnimator alloc]
initWithDuration:self.duration
curve:UIViewAnimationCurveEaseOut
animations:fadeInCellsKeyframeAnimation];
[self.animations addAnimator:fadeInCells];
}
- (void)buildExpandingAnimations {
// The transition is structured as four to six separate animations. They are
// timed based on two sub-durations which are expressed as fractions of the
// overall animation duration.
CGFloat partialDuration = 0.66;
CGFloat briefDuration = 0.3;
CGFloat delay = 0.1;
// Damping ratio for the resize animation.
CGFloat resizeDamping = 0.7 + _resizeDampingCorrection;
// If there's only one cell, the animation has three parts:
// (A) Zooming the active cell out into the expanded position.
// (B) Crossfading the active cell's top views.
// (C) Squaring the corners of the active cell.
// (D) Fading out the main cell view and fading in the main tab view, if
// necessary.
// These parts are timed over `duration` like this:
//
// {0%}--[A]-----------------------------------{100%}
// {0%}--[B]---{30%}
// {0%}--[C]---{30%}
// {10%}--[D]---{40%}
// If there's more than once cell, the animation adds:
// (E) Scaling the inactive cells to 95%
// (F) Fading out the inactive cells.
// The overall timing is as follows:
//
// {0%}--[A]-----------------------------------{100%}
// {0%}--[B]---{30%}
// {0%}--[C]---{30%}
// {10%}--[D]---{40%}
// {0%}--[E]-----------------------------------{100%}
// {0%}--[F]-------------------{66%}
//
// All animations are timed ease-out (so more motion happens sooner), except
// for B, C and D. B is a crossfade and eases in/out. C and D are relatively
// short in duration; they have linear timing so they doesn't seem
// instantaneous, and D is also linear so that identical views animate
// smoothly.
//
// Animation D is necessary because the cell content and the tab content may
// no longer match in aspect ratio; a quick cross-fade in mid-transition
// prevents an abrupt jump when the transition ends and the "real" tab content
// is shown.
UIView<LegacyGridToTabTransitionView>* activeCell =
self.layout.activeItem.cell;
// The top tab view starts at zero alpha but is crossfaded in.
activeCell.topTabView.alpha = 0.0;
// If the active item is appearing, the main tab view is shown. If not, it's
// hidden, and may be faded in if it's expected to be different in content
// from the existing cell snapshot.
if (!self.layout.activeItem.isAppearing) {
activeCell.mainTabView.alpha = 0.0;
}
// A: Zoom the active cell into position.
UIViewPropertyAnimator* zoomActiveCell =
[[UIViewPropertyAnimator alloc] initWithDuration:self.duration
dampingRatio:resizeDamping
animations:^{
[self positionExpandedActiveItem];
}];
[self.animations addAnimator:zoomActiveCell];
// B: Crossfade the top views.
auto fadeOutAuxilliaryAnimation =
[self keyframeAnimationWithRelativeStart:0
relativeDuration:briefDuration
animations:^{
activeCell.topCellView.alpha = 0;
activeCell.topTabView.alpha = 1.0;
}];
UIViewPropertyAnimator* fadeOutAuxilliary = [[UIViewPropertyAnimator alloc]
initWithDuration:self.duration
curve:UIViewAnimationCurveEaseInOut
animations:fadeOutAuxilliaryAnimation];
[self.animations addAnimator:fadeOutAuxilliary];
// C: Square the active cell's corners.
UIView<LegacyGridToTabTransitionView>* cell = self.layout.activeItem.cell;
auto squareCornersAnimation = ^{
cell.cornerRadius = DeviceCornerRadius();
};
auto squareCornersKeyframeAnimation =
[self keyframeAnimationWithRelativeStart:0.0
relativeDuration:briefDuration
animations:squareCornersAnimation];
UIViewPropertyAnimator* squareCorners = [[UIViewPropertyAnimator alloc]
initWithDuration:self.duration
curve:UIViewAnimationCurveLinear
animations:squareCornersKeyframeAnimation];
[self.animations addAnimator:squareCorners];
// D: crossfade the main cell content, if necessary.
// This crossfade is needed if the aspect ratio of the tab being animated
// to doesn't match the aspect ratio of the tab that originally generated the
// cell content being animated; this happens when the tab grid is exited in a
// diffferent orientation than it was entered.
// Using a linear animation curve means that the sum of the opacities is
// contstant though the animation, which will help it seem less abrupt by
// keeping a relatively constant brightness.
if (self.layout.frameChanged) {
auto crossfadeContentAnimation =
[self keyframeAnimationWithRelativeStart:delay
relativeDuration:briefDuration
animations:^{
activeCell.mainCellView.alpha = 0;
activeCell.mainTabView.alpha = 1.0;
}];
UIViewPropertyAnimator* crossfadeContent = [[UIViewPropertyAnimator alloc]
initWithDuration:self.duration
curve:UIViewAnimationCurveLinear
animations:crossfadeContentAnimation];
[self.animations addAnimator:crossfadeContent];
}
// If there's only a single cell, that's all.
if (self.layout.inactiveItems.count == 0) {
return;
}
// Additional animations for multiple cells.
// E: Scale down inactive cells.
auto scaleDownCellsAnimation = ^{
for (LegacyGridTransitionItem* item in self.layout.inactiveItems) {
item.cell.transform = CGAffineTransformScale(
item.cell.transform, kInactiveItemScale, kInactiveItemScale);
}
};
UIViewPropertyAnimator* scaleDownCells = [[UIViewPropertyAnimator alloc]
initWithDuration:self.duration
curve:UIViewAnimationCurveEaseOut
animations:scaleDownCellsAnimation];
[self.animations addAnimator:scaleDownCells];
// F: Fade out inactive cells.
auto fadeOutCellsAnimation = ^{
for (LegacyGridTransitionItem* item in self.layout.inactiveItems) {
item.cell.alpha = 0.0;
}
};
auto fadeOutCellsKeyframeAnimation =
[self keyframeAnimationWithRelativeStart:0
relativeDuration:partialDuration
animations:fadeOutCellsAnimation];
UIViewPropertyAnimator* fadeOutCells = [[UIViewPropertyAnimator alloc]
initWithDuration:self.duration
curve:UIViewAnimationCurveEaseOut
animations:fadeOutCellsKeyframeAnimation];
[self.animations addAnimator:fadeOutCells];
}
// Performs the initial setup for the animation, computing scale based on the
// superview size and adding the transition cells to the view hierarchy.
- (void)prepareForAnimationInSuperview:(UIView*)newSuperview {
CAShapeLayer* maskLayer = [[CAShapeLayer alloc] init];
// The path needs to be released explicitly.
CGPathRef path = CGPathCreateWithRect(_gridContainerFrame, NULL);
maskLayer.path = path;
CGPathRelease(path);
self.layer.mask = maskLayer;
// Add the selection item first, so it's under ther other views.
[self addSubview:self.layout.selectionItem.cell];
for (LegacyGridTransitionItem* item in self.layout.inactiveItems) {
[self addSubview:item.cell];
}
// Add the active item last so it's always the top subview.
[self addSubview:self.layout.activeItem.cell];
}
// Prepares the the views for a transition.
- (void)prepareForTransition {
UIView<LegacyGridToTabTransitionView>* cell = self.layout.activeItem.cell;
[cell prepareForTransitionWithAnimationDirection:self.direction];
}
// Positions the active item in the expanded grid position with a zero corner
// radius and a 0% opacity auxilliary view.
- (void)positionExpandedActiveItem {
UIView<LegacyGridToTabTransitionView>* cell = self.layout.activeItem.cell;
cell.frame = self.layout.expandedRect;
[cell positionTabViews];
}
// Positions all of the inactive items in their grid positions.
// Fades and scales each of those items.
- (void)prepareInactiveItemsForAppearance {
for (LegacyGridTransitionItem* item in self.layout.inactiveItems) {
[self positionItemInGrid:item];
item.cell.alpha = 0.2;
item.cell.transform = CGAffineTransformScale(
item.cell.transform, kInactiveItemScale, kInactiveItemScale);
}
[self positionItemInGrid:self.layout.selectionItem];
}
// Positions the active item in the regular grid position with its final
// corner radius.
- (void)positionAndScaleActiveItemInGrid {
UIView<LegacyGridToTabTransitionView>* cell = self.layout.activeItem.cell;
cell.transform = CGAffineTransformIdentity;
CGRect frame = cell.frame;
frame.size = self.layout.activeItem.size;
cell.frame = frame;
[self positionItemInGrid:self.layout.activeItem];
[cell positionCellViews];
}
// Prepares all of the items for an expansion animation.
- (void)prepareAllItemsForExpansion {
for (LegacyGridTransitionItem* item in self.layout.inactiveItems) {
[self positionItemInGrid:item];
}
[self positionItemInGrid:self.layout.activeItem];
[self.layout.activeItem.cell positionCellViews];
[self positionItemInGrid:self.layout.selectionItem];
}
// Positions `item` in it grid position.
- (void)positionItemInGrid:(LegacyGridTransitionItem*)item {
UIView* cell = item.cell;
CGPoint newCenter = [self.superview convertPoint:item.center fromView:nil];
cell.center = newCenter;
}
// Helper function to construct keyframe animation blocks.
// Given `start` and `duration` (in the [0.0-1.0] interval), returns an
// animation block which runs `animations` starting at `start` (relative to
// `self.duration`) and running for `duration` (likewise).
- (void (^)(void))keyframeAnimationWithRelativeStart:(double)start
relativeDuration:(double)duration
animations:
(void (^)(void))animations {
auto keyframe = ^{
[UIView addKeyframeWithRelativeStartTime:start
relativeDuration:duration
animations:animations];
};
return ^{
[UIView animateKeyframesWithDuration:self.duration
delay:0
options:UIViewAnimationOptionLayoutSubviews
animations:keyframe
completion:nil];
};
}
// Returns a cross-fade keyframe animation between two views.
// `startView` should have an alpha of 1; `endView` should have an alpha of 0.
// `start` and `duration` are in the [0.0]-[1.0] interval and represent timing
// relative to `self.duration`.
// The animation returned by this method will fade `startView` to 0 over the
// first half of `duration`, and then fade `endView` to 1.0 over the second
// half, preventing any blurred frames showing both views. For best results, the
// animation curev should be EaseInEaseOut.
- (void (^)(void))keyframeAnimationFadingView:(UIView*)startView
throughToView:(UIView*)endView
relativeDuration:(double)duration {
CGFloat halfDuration = duration / 2;
auto keyframes = ^{
[UIView addKeyframeWithRelativeStartTime:0
relativeDuration:halfDuration
animations:^{
startView.alpha = 0.0;
}];
[UIView addKeyframeWithRelativeStartTime:halfDuration
relativeDuration:halfDuration
animations:^{
endView.alpha = 1.0;
}];
};
return ^{
[UIView animateKeyframesWithDuration:self.duration
delay:0
options:UIViewAnimationOptionLayoutSubviews
animations:keyframes
completion:nil];
};
}
@end