blob: 6ff7f063cdaa461de0d6781098f4ec19f577ea39 [file] [log] [blame]
// Copyright 2017 The Chromium Authors. All rights reserved.
// 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/content_suggestions/content_suggestions_header_synchronizer.h"
#include "base/ios/ios_util.h"
#import "base/mac/foundation_util.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/content_suggestions_cell.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/content_suggestions_most_visited_action_cell.h"
#import "ios/chrome/browser/ui/content_suggestions/cells/content_suggestions_most_visited_cell.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_collection_controlling.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_collection_utils.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_header_controlling.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_view_controller.h"
#import "ios/chrome/browser/ui/util/uikit_ui_util.h"
#if !defined(__has_feature) || !__has_feature(objc_arc)
#error "This file requires ARC support."
#endif
namespace {
const CGFloat kShiftTilesDownAnimationDuration = 0.2;
const CGFloat kShiftTilesUpAnimationDuration = 0.25;
} // namespace
@interface ContentSuggestionsHeaderSynchronizer ()<UIGestureRecognizerDelegate>
@property(nonatomic, weak, readonly) UICollectionView* collectionView;
// |YES| if the fakebox header should be animated on scroll.
@property(nonatomic, assign) BOOL shouldAnimateHeader;
@property(nonatomic, weak) id<ContentSuggestionsCollectionControlling>
collectionController;
@property(nonatomic, weak) id<ContentSuggestionsHeaderControlling>
headerController;
@property(nonatomic, assign) CFTimeInterval shiftTileStartTime;
// Tap gesture recognizer when the omnibox is focused.
@property(nonatomic, strong) UITapGestureRecognizer* tapGestureRecognizer;
// Animator for the shiftTilesUp animation.
@property(nonatomic, strong) UIViewPropertyAnimator* animator;
@end
@implementation ContentSuggestionsHeaderSynchronizer
@synthesize collectionController = _collectionController;
@synthesize headerController = _headerController;
@synthesize shouldAnimateHeader = _shouldAnimateHeader;
@synthesize shiftTileStartTime = _shiftTileStartTime;
@synthesize tapGestureRecognizer = _tapGestureRecognizer;
@synthesize collectionShiftingOffset = _collectionShiftingOffset;
- (instancetype)
initWithCollectionController:
(id<ContentSuggestionsCollectionControlling>)collectionController
headerController:
(id<ContentSuggestionsHeaderControlling>)headerController {
self = [super init];
if (self) {
_shiftTileStartTime = -1;
_shouldAnimateHeader = YES;
_tapGestureRecognizer = [[UITapGestureRecognizer alloc]
initWithTarget:self
action:@selector(unfocusOmnibox)];
[_tapGestureRecognizer setDelegate:self];
_headerController = headerController;
_collectionController = collectionController;
_headerController.collectionSynchronizer = self;
_collectionController.headerSynchronizer = self;
_collectionShiftingOffset = 0;
}
return self;
}
#pragma mark - ContentSuggestionsCollectionSynchronizing
- (void)shiftTilesDown {
[self.collectionView removeGestureRecognizer:self.tapGestureRecognizer];
self.shouldAnimateHeader = YES;
if (self.animator.running) {
[self.animator stopAnimation:NO];
[self.animator finishAnimationAtPosition:UIViewAnimatingPositionStart];
self.animator = nil;
}
if (self.collectionShiftingOffset == 0 || self.collectionView.dragging) {
self.collectionShiftingOffset = 0;
[self updateFakeOmniboxOnCollectionScroll];
return;
}
self.collectionController.scrolledToTop = NO;
// CADisplayLink is used for this animation instead of the standard UIView
// animation because the standard animation did not properly convert the
// fakebox from its scrolled up mode to its scrolled down mode. Specifically,
// calling |UICollectionView reloadData| adjacent to the standard animation
// caused the fakebox's views to jump incorrectly. CADisplayLink avoids this
// problem because it allows |shiftTilesDownAnimationDidFire| to directly
// control each frame.
CADisplayLink* link = [CADisplayLink
displayLinkWithTarget:self
selector:@selector(shiftTilesDownAnimationDidFire:)];
[link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
}
- (void)shiftTilesUpWithAnimations:(ProceduralBlock)animations
completion:
(void (^)(UIViewAnimatingPosition))completion {
// Add gesture recognizer to collection view when the omnibox is focused.
[self.collectionView addGestureRecognizer:self.tapGestureRecognizer];
if (self.collectionView.decelerating) {
// Stop the scrolling if the scroll view is decelerating to prevent the
// focus to be immediately lost.
[self.collectionView setContentOffset:self.collectionView.contentOffset
animated:NO];
}
if (self.collectionController.scrolledToTop) {
self.shouldAnimateHeader = NO;
if (completion)
completion(UIViewAnimatingPositionEnd);
return;
}
if (CGSizeEqualToSize(self.collectionView.contentSize, CGSizeZero))
[self.collectionView layoutIfNeeded];
CGFloat pinnedOffsetY = [self.headerController pinnedOffsetY];
self.collectionShiftingOffset =
MAX(0, pinnedOffsetY - self.collectionView.contentOffset.y);
self.collectionController.scrolledToTop = YES;
self.shouldAnimateHeader = YES;
__weak __typeof(self) weakSelf = self;
self.animator = [[UIViewPropertyAnimator alloc]
initWithDuration:kShiftTilesUpAnimationDuration
curve:UIViewAnimationCurveEaseInOut
animations:^{
if (!weakSelf)
return;
__typeof(weakSelf) strongSelf = weakSelf;
if (strongSelf.collectionView.contentOffset.y < pinnedOffsetY) {
if (animations)
animations();
// Changing the contentOffset of the collection results in a
// scroll and a change in the constraints of the header.
strongSelf.collectionView.contentOffset =
CGPointMake(0, pinnedOffsetY);
// Layout the header for the constraints to be animated.
[strongSelf.headerController layoutHeader];
[strongSelf.collectionView
.collectionViewLayout invalidateLayout];
}
}];
[self.animator addCompletion:^(UIViewAnimatingPosition finalPosition) {
if (!weakSelf)
return;
if (finalPosition == UIViewAnimatingPositionEnd)
weakSelf.shouldAnimateHeader = NO;
if (completion)
completion(finalPosition);
}];
self.animator.interruptible = YES;
[self.animator startAnimation];
}
- (void)invalidateLayout {
[self updateFakeOmniboxOnNewWidth:self.collectionView.bounds.size.width];
[self.collectionView.collectionViewLayout invalidateLayout];
if (@available(iOS 13, *)) {
dispatch_async(dispatch_get_main_queue(), ^{
// On iOS 13, invalidating the layout doesn't reset the positioning of the
// header. To make sure that it is correctly positioned, scroll 1pt. This
// is done in the next runloop to have the collectionView resized and the
// content offset set to the new value. See crbug.com/1025694.
CGPoint currentOffset = [self.collectionView contentOffset];
currentOffset.y += 1;
[self.collectionView setContentOffset:currentOffset animated:YES];
});
}
}
#pragma mark - ContentSuggestionsHeaderSynchronizing
- (void)updateFakeOmniboxOnCollectionScroll {
// Unfocus the omnibox when the scroll view is scrolled by the user (but not
// when a scroll is triggered by layout/UIKit).
if ([self.headerController isOmniboxFocused] && !self.shouldAnimateHeader &&
self.collectionView.dragging) {
[self.headerController unfocusOmnibox];
}
if (self.shouldAnimateHeader) {
UIEdgeInsets insets = self.collectionView.safeAreaInsets;
[self.headerController
updateFakeOmniboxForOffset:self.collectionView.contentOffset.y
screenWidth:self.collectionView.frame.size.width
safeAreaInsets:insets];
}
}
- (void)updateFakeOmniboxOnNewWidth:(CGFloat)width {
if (self.shouldAnimateHeader) {
// We check -superview here because in certain scenarios (such as when the
// VC is rotated underneath another presented VC), in a
// UICollectionViewController -viewSafeAreaInsetsDidChange the VC.view has
// updated safeAreaInsets, but VC.collectionView does not until a layer
// -viewDidLayoutSubviews. Since self.collectionView and it's superview
// should always have the same safeArea, this should be safe.
UIEdgeInsets insets = self.collectionView.superview.safeAreaInsets;
[self.headerController
updateFakeOmniboxForOffset:self.collectionView.contentOffset.y
screenWidth:width
safeAreaInsets:insets];
} else {
[self.headerController updateFakeOmniboxForWidth:width];
}
}
- (void)updateConstraints {
[self.headerController updateConstraints];
}
- (void)resetPreFocusOffset {
self.collectionShiftingOffset = 0;
}
- (void)unfocusOmnibox {
[self.headerController unfocusOmnibox];
}
- (CGFloat)pinnedOffsetY {
return [self.headerController pinnedOffsetY];
}
- (CGFloat)headerHeight {
return [self.headerController headerHeight];
}
- (void)setShowing:(BOOL)showing {
self.headerController.showing = showing;
}
- (BOOL)isShowing {
return self.headerController.isShowing;
}
#pragma mark - Private
// Convenience method to get the collection view of the suggestions.
- (UICollectionView*)collectionView {
return [self.collectionController collectionView];
}
// Updates the collection view's scroll view offset for the next frame of the
// shiftTilesDown animation.
- (void)shiftTilesDownAnimationDidFire:(CADisplayLink*)link {
// If this is the first frame of the animation, store the starting timestamp
// and do nothing.
if (self.shiftTileStartTime == -1) {
self.shiftTileStartTime = link.timestamp;
return;
}
CFTimeInterval timeElapsed = link.timestamp - self.shiftTileStartTime;
double percentComplete = timeElapsed / kShiftTilesDownAnimationDuration;
// Ensure that the percentage cannot be above 1.0.
if (percentComplete > 1.0)
percentComplete = 1.0;
// Find how much the collection view should be scrolled up in the next frame.
CGFloat yOffset =
(1.0 - percentComplete) * [self.headerController pinnedOffsetY] +
percentComplete * MAX([self.headerController pinnedOffsetY] -
self.collectionShiftingOffset,
0);
self.collectionView.contentOffset = CGPointMake(0, yOffset);
if (percentComplete == 1.0) {
[link invalidate];
self.collectionShiftingOffset = 0;
// Reset |shiftTileStartTime| to its sentinel value.
self.shiftTileStartTime = -1;
}
}
#pragma mark - UIGestureRecognizerDelegate
- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer
shouldReceiveTouch:(UITouch*)touch {
BOOL isMostVisitedCell =
content_suggestions::nearestAncestor(
touch.view, [ContentSuggestionsMostVisitedCell class]) != nil;
BOOL isMostVisitedActionCell =
content_suggestions::nearestAncestor(
touch.view, [ContentSuggestionsMostVisitedActionCell class]) != nil;
BOOL isSuggestionCell =
content_suggestions::nearestAncestor(
touch.view, [ContentSuggestionsCell class]) != nil;
return !isMostVisitedCell && !isMostVisitedActionCell && !isSuggestionCell;
}
- (UIView*)nearestAncestorOfView:(UIView*)view withClass:(Class)aClass {
if (!view) {
return nil;
}
if ([view isKindOfClass:aClass]) {
return view;
}
return [self nearestAncestorOfView:[view superview] withClass:aClass];
}
@end