| Index: ios/chrome/browser/ui/contextual_search/contextual_search_header_view.mm
|
| diff --git a/ios/chrome/browser/ui/contextual_search/contextual_search_header_view.mm b/ios/chrome/browser/ui/contextual_search/contextual_search_header_view.mm
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..f156b866fcc44088d0a8f92190aad72580efb915
|
| --- /dev/null
|
| +++ b/ios/chrome/browser/ui/contextual_search/contextual_search_header_view.mm
|
| @@ -0,0 +1,395 @@
|
| +// Copyright 2014 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/contextual_search/contextual_search_header_view.h"
|
| +
|
| +#import "base/ios/weak_nsobject.h"
|
| +#include "base/logging.h"
|
| +#include "base/mac/scoped_cftyperef.h"
|
| +#include "base/mac/scoped_nsobject.h"
|
| +#import "ios/chrome/browser/ui/contextual_search/contextual_search_panel_view.h"
|
| +#import "ios/chrome/browser/ui/uikit_ui_util.h"
|
| +#import "ios/chrome/common/material_timing.h"
|
| +#include "ios/chrome/common/string_util.h"
|
| +#include "ios/public/provider/chrome/browser/chrome_browser_provider.h"
|
| +#include "ios/public/provider/chrome/browser/images/branded_image_provider.h"
|
| +#import "ios/third_party/material_components_ios/src/components/Typography/src/MaterialTypography.h"
|
| +
|
| +namespace {
|
| +const CGFloat kHorizontalMargin = 24.0;
|
| +const CGFloat kHorizontalLayoutGap = 16.0;
|
| +
|
| +const NSTimeInterval kTextTransformAnimationDuration =
|
| + ios::material::kDuration1;
|
| +const NSTimeInterval kLogoIrisAnimationDuration = ios::material::kDuration1;
|
| +} // namespace
|
| +
|
| +// An image that can "iris" in/out. Assumes a square image and will do a stupid-
|
| +// looking eliptical iris otherwise.
|
| +@interface IrisingImageView : UIImageView
|
| +// |iris| is the degree that the logo is irised; a value of 0.0 indicates
|
| +// the logo is completly invisible, a 1.0 indicates it is completely visible,
|
| +// and 0.5 indicates the iris is open to show a diameter half of the image size.
|
| +// |iris| has an initial value of 1.0.
|
| +// |iris| is animatable, in that setting in inside an animation block will
|
| +// cause the transition to be animated.
|
| +@property(nonatomic, assign) CGFloat iris;
|
| +@end
|
| +
|
| +@implementation IrisingImageView {
|
| + CGFloat _iris;
|
| +}
|
| +
|
| +@synthesize iris = _iris;
|
| +
|
| +// Create a mask layer for the iris effect
|
| +- (instancetype)initWithImage:(UIImage*)image {
|
| + if ((self = [super initWithImage:image])) {
|
| + CAShapeLayer* maskLayer = [CAShapeLayer layer];
|
| + maskLayer.bounds = self.bounds;
|
| + base::ScopedCFTypeRef<CGPathRef> path(
|
| + CGPathCreateWithEllipseInRect(maskLayer.bounds, NULL));
|
| + maskLayer.path = path;
|
| + maskLayer.fillColor = [UIColor whiteColor].CGColor;
|
| + maskLayer.position =
|
| + CGPointMake(CGRectGetMidX(self.bounds), CGRectGetMidY(self.bounds));
|
| + self.layer.mask = maskLayer;
|
| + self.iris = 1.0;
|
| + [self setContentHuggingPriority:UILayoutPriorityDefaultHigh
|
| + forAxis:UILayoutConstraintAxisVertical];
|
| + [self setContentHuggingPriority:UILayoutPriorityDefaultHigh
|
| + forAxis:UILayoutConstraintAxisHorizontal];
|
| + }
|
| + return self;
|
| +}
|
| +
|
| +- (void)setIris:(CGFloat)iris {
|
| + _iris = iris;
|
| + // Transform the (0.0 ... 1.0) iris value so that the area covered appears
|
| + // to change linearly. A value of 0.5 should cover half the area of the image,
|
| + // so the radius of the circle should be sqrt(0.5); so, use sqrt(_iris).
|
| + // At a scale of 1.0, the iris should totally expose the image, so the iris
|
| + // diameter must be enough to encompass a square the size of the image; for a
|
| + // unit square, that's sqrt(2).
|
| + CGFloat scale = sqrt(_iris) * sqrt(2.0);
|
| + [self.layer.mask setAffineTransform:CGAffineTransformMakeScale(scale, scale)];
|
| +}
|
| +
|
| +@end
|
| +
|
| +// Button subclass whose intrinsic content size is always large enough to be
|
| +// easily tappable.
|
| +@interface TappableButton : UIButton
|
| +@end
|
| +
|
| +@implementation TappableButton
|
| +
|
| +- (CGSize)intrinsicContentSize {
|
| + CGSize contentSize = [super intrinsicContentSize];
|
| + contentSize.height = MAX(contentSize.height, 44.0);
|
| + contentSize.width = MAX(contentSize.width, 44.0);
|
| + return contentSize;
|
| +}
|
| +
|
| +@end
|
| +
|
| +@implementation ContextualSearchHeaderView {
|
| + CGFloat _height;
|
| + // Circular logo positioned leading side.
|
| + __unsafe_unretained IrisingImageView* _logo;
|
| + // Up/down caret positioned trailing side.
|
| + __unsafe_unretained UIImageView* _caret;
|
| + // Close control position identically to the caret.
|
| + __unsafe_unretained TappableButton* _closeButton;
|
| + // Label showing the text the user tapped on in the web page, and any
|
| + // additional context that will be displayed.
|
| + __unsafe_unretained UILabel* _textLabel;
|
| + base::WeakNSProtocol<id<ContextualSearchPanelTapHandler>> _tapHandler;
|
| + base::scoped_nsobject<UIGestureRecognizer> _tapRecognizer;
|
| +}
|
| +
|
| ++ (BOOL)requiresConstraintBasedLayout {
|
| + return YES;
|
| +}
|
| +
|
| +- (instancetype)initWithHeight:(CGFloat)height {
|
| + if (!(self = [super initWithFrame:CGRectZero]))
|
| + return nil;
|
| +
|
| + DCHECK(height > 0);
|
| + _height = height;
|
| +
|
| + self.translatesAutoresizingMaskIntoConstraints = NO;
|
| + self.backgroundColor = [UIColor whiteColor];
|
| + _tapRecognizer.reset([[UITapGestureRecognizer alloc] init]);
|
| + [self addGestureRecognizer:_tapRecognizer];
|
| + [_tapRecognizer addTarget:self action:@selector(panelWasTapped:)];
|
| +
|
| + UIImage* logoImage = ios::GetChromeBrowserProvider()
|
| + ->GetBrandedImageProvider()
|
| + ->GetContextualSearchHeaderImage();
|
| + _logo = [[[IrisingImageView alloc] initWithImage:logoImage] autorelease];
|
| + _logo.translatesAutoresizingMaskIntoConstraints = NO;
|
| + _logo.iris = 0.0;
|
| +
|
| + _caret = [[[UIImageView alloc]
|
| + initWithImage:[UIImage imageNamed:@"expand_less"]] autorelease];
|
| + _caret.translatesAutoresizingMaskIntoConstraints = NO;
|
| + [_caret setContentHuggingPriority:UILayoutPriorityDefaultHigh
|
| + forAxis:UILayoutConstraintAxisVertical];
|
| + [_caret setContentHuggingPriority:UILayoutPriorityDefaultHigh
|
| + forAxis:UILayoutConstraintAxisHorizontal];
|
| +
|
| + _closeButton =
|
| + [[[TappableButton alloc] initWithFrame:CGRectZero] autorelease];
|
| + _closeButton.translatesAutoresizingMaskIntoConstraints = NO;
|
| + [_closeButton setImage:[UIImage imageNamed:@"card_close_button"]
|
| + forState:UIControlStateNormal];
|
| + [_closeButton setImage:[UIImage imageNamed:@"card_close_button_pressed"]
|
| + forState:UIControlStateHighlighted];
|
| + [_closeButton setContentHuggingPriority:UILayoutPriorityDefaultHigh
|
| + forAxis:UILayoutConstraintAxisVertical];
|
| + [_closeButton setContentHuggingPriority:UILayoutPriorityDefaultHigh
|
| + forAxis:UILayoutConstraintAxisHorizontal];
|
| + _closeButton.alpha = 0;
|
| +
|
| + _textLabel = [[[UILabel alloc] initWithFrame:CGRectZero] autorelease];
|
| + _textLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
| + _textLabel.font = [MDCTypography subheadFont];
|
| + _textLabel.textAlignment = NSTextAlignmentNatural;
|
| + _textLabel.lineBreakMode = NSLineBreakByCharWrapping;
|
| + // Ensure that |_textLabel| doesn't expand past the space defined for it
|
| + // regardless of how long its text is.
|
| + [_textLabel setContentHuggingPriority:UILayoutPriorityDefaultLow
|
| + forAxis:UILayoutConstraintAxisHorizontal];
|
| +
|
| + [self setAccessibilityIdentifier:@"header"];
|
| + [_logo setAccessibilityIdentifier:@"logo"];
|
| + [_caret setAccessibilityIdentifier:@"caret"];
|
| + [_closeButton setAccessibilityIdentifier:@"close"];
|
| + [_textLabel setAccessibilityIdentifier:@"selectedText"];
|
| +
|
| + [self addSubview:_logo];
|
| + [self addSubview:_caret];
|
| + [self addSubview:_textLabel];
|
| + [self addSubview:_closeButton];
|
| +
|
| + [self setLayoutMargins:UIEdgeInsetsMake(0, kHorizontalMargin, 0,
|
| + kHorizontalMargin)];
|
| +
|
| + [NSLayoutConstraint activateConstraints:@[
|
| + // Horizontal layout:
|
| + // Logo is at the leading margin:
|
| + [_logo.leadingAnchor
|
| + constraintEqualToAnchor:self.layoutMarginsGuide.leadingAnchor],
|
| + // Caret is at the trailing margin:
|
| + [_caret.trailingAnchor
|
| + constraintEqualToAnchor:self.layoutMarginsGuide.trailingAnchor],
|
| + // Close button is centered over the caret:
|
| + [_closeButton.centerXAnchor constraintEqualToAnchor:_caret.centerXAnchor],
|
| + // The available space for the text label is the space (minus
|
| + // |kHorizontalLayoutGap| on each side) between the logo and the caret:
|
| + [_textLabel.leadingAnchor constraintEqualToAnchor:_logo.trailingAnchor
|
| + constant:kHorizontalLayoutGap],
|
| + [_textLabel.trailingAnchor
|
| + constraintLessThanOrEqualToAnchor:_caret.leadingAnchor
|
| + constant:-kHorizontalLayoutGap],
|
| + // Vertical layout:
|
| + // Everything is center-aligned to |self|.
|
| + [_logo.centerYAnchor
|
| + constraintEqualToAnchor:self.layoutMarginsGuide.centerYAnchor],
|
| + [_textLabel.centerYAnchor
|
| + constraintEqualToAnchor:self.layoutMarginsGuide.centerYAnchor],
|
| + [_caret.centerYAnchor
|
| + constraintEqualToAnchor:self.layoutMarginsGuide.centerYAnchor],
|
| + [_closeButton.centerYAnchor constraintEqualToAnchor:_caret.centerYAnchor],
|
| + ]];
|
| +
|
| + return self;
|
| +}
|
| +
|
| +- (instancetype)initWithCoder:(NSCoder*)aDecoder NS_UNAVAILABLE {
|
| + NOTREACHED();
|
| + return nil;
|
| +}
|
| +
|
| +- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE {
|
| + NOTREACHED();
|
| + return nil;
|
| +}
|
| +
|
| +#pragma mark - property implementation.
|
| +
|
| +- (void)setTapHandler:(id<ContextualSearchPanelTapHandler>)tapHandler {
|
| + if (_tapHandler) {
|
| + [_closeButton removeTarget:_tapHandler
|
| + action:@selector(closePanel)
|
| + forControlEvents:UIControlEventTouchUpInside];
|
| + }
|
| + _tapHandler.reset(tapHandler);
|
| + if (_tapHandler) {
|
| + [_closeButton addTarget:_tapHandler
|
| + action:@selector(closePanel)
|
| + forControlEvents:UIControlEventTouchUpInside];
|
| + }
|
| +}
|
| +
|
| +- (id<ContextualSearchPanelTapHandler>)tapHandler {
|
| + return _tapHandler;
|
| +}
|
| +
|
| +- (void)panelWasTapped:(UIGestureRecognizer*)gestureRecognizer {
|
| + for (NSUInteger touchIndex = 0;
|
| + touchIndex < gestureRecognizer.numberOfTouches; touchIndex++) {
|
| + if (!CGRectContainsPoint(
|
| + self.frame,
|
| + [gestureRecognizer locationOfTouch:touchIndex inView:self])) {
|
| + return;
|
| + }
|
| + }
|
| + [_tapHandler panelWasTapped:gestureRecognizer];
|
| +}
|
| +
|
| +#pragma mark - UIView layout methods
|
| +
|
| +- (CGSize)intrinsicContentSize {
|
| + // This view's height is always |_height|.
|
| + return CGSizeMake(UIViewNoIntrinsicMetric, _height);
|
| +}
|
| +
|
| +#pragma mark - ContextualSearchPanelMotionObserver
|
| +
|
| +- (void)panel:(ContextualSearchPanelView*)panel
|
| + didMoveWithMotion:(ContextualSearch::PanelMotion)motion {
|
| + if (motion.state == ContextualSearch::PREVIEWING) {
|
| + [self setCloseButtonTransition:1.0];
|
| + }
|
| + if (motion.state == ContextualSearch::PEEKING) {
|
| + _caret.alpha = 1.0;
|
| + [self setCloseButtonTransition:motion.gradation];
|
| + }
|
| +}
|
| +
|
| +- (void)panelWillPromote:(ContextualSearchPanelView*)panel {
|
| + // Disable tap handling.
|
| + self.tapHandler = nil;
|
| +}
|
| +
|
| +- (void)panelIsPromoting:(ContextualSearchPanelView*)panel {
|
| + self.alpha = 0.0;
|
| + [panel removeMotionObserver:self];
|
| +}
|
| +
|
| +#pragma mark - Subview update
|
| +
|
| +- (void)setCloseButtonTransition:(CGFloat)gradation {
|
| + // Crossfade the caret into the close button by fading the caret all the way
|
| + // out, and then fading the close button in.
|
| + // As the overall gradation moves from 0 to 0.5, the caret's alpha moves
|
| + // from 1.0 to 0, and as the gradation continues from 0.5 to 1.0, the
|
| + // close button's alpha moves from 0 to 1.0.
|
| + CGFloat scaledGradation = 1 - (2 * gradation); // [0, 1] -> [1, -1]
|
| + CGFloat caretGradation = MAX(scaledGradation, 0); // [1.0 .. 0.0 .. 0.0]
|
| + CGFloat closeGradation = MAX(-scaledGradation, 0); // [0.0 .. 0.0 .. 1.0]
|
| + _caret.alpha = caretGradation * caretGradation;
|
| + _closeButton.alpha = closeGradation * closeGradation;
|
| +}
|
| +
|
| +#pragma mark - Animated transitions
|
| +
|
| +- (void)setText:(NSString*)text
|
| + followingTextRange:(NSRange)followingTextRange
|
| + animated:(BOOL)animated {
|
| + NSMutableAttributedString* styledText =
|
| + [[[NSMutableAttributedString alloc] initWithString:text] autorelease];
|
| + [styledText addAttribute:NSForegroundColorAttributeName
|
| + value:[UIColor colorWithWhite:0 alpha:0.71f]
|
| + range:followingTextRange];
|
| +
|
| + void (^transform)(void) = ^{
|
| + _textLabel.attributedText = styledText;
|
| + };
|
| + void (^complete)(BOOL) = ^(BOOL finished) {
|
| + [self showLogoAnimated:animated];
|
| + };
|
| +
|
| + if (animated) {
|
| + UIViewAnimationOptions options =
|
| + UIViewAnimationOptionTransitionCrossDissolve;
|
| + [UIView cr_transitionWithView:self
|
| + duration:kTextTransformAnimationDuration
|
| + curve:ios::material::CurveEaseOut
|
| + options:options
|
| + animations:transform
|
| + completion:complete];
|
| + } else {
|
| + transform();
|
| + complete(NO);
|
| + }
|
| +}
|
| +
|
| +- (void)setSearchTerm:(NSString*)searchTerm animated:(BOOL)animated {
|
| + void (^transform)(void) = ^{
|
| + _textLabel.text = searchTerm;
|
| + };
|
| + void (^complete)(BOOL) = ^(BOOL finished) {
|
| + [self showLogoAnimated:animated];
|
| + };
|
| +
|
| + if (animated) {
|
| + UIViewAnimationOptions options =
|
| + UIViewAnimationOptionTransitionCrossDissolve;
|
| + [UIView cr_transitionWithView:self
|
| + duration:kTextTransformAnimationDuration
|
| + curve:ios::material::CurveEaseInOut
|
| + options:options
|
| + animations:transform
|
| + completion:complete];
|
| + } else {
|
| + transform();
|
| + complete(NO);
|
| + }
|
| +}
|
| +
|
| +- (void)showLogoAnimated:(BOOL)animated {
|
| + // Since the logo is round, we only need to animate to 1/sqrt(2) to display
|
| + // the whole thing.
|
| + if ([_logo iris] > 0.0)
|
| + return;
|
| +
|
| + void (^transform)(void) = ^{
|
| + [_logo setIris:(1.0 / sqrt(2.0))];
|
| + };
|
| + if (animated) {
|
| + [UIView cr_animateWithDuration:kLogoIrisAnimationDuration
|
| + delay:0
|
| + curve:ios::material::CurveEaseIn
|
| + options:0
|
| + animations:transform
|
| + completion:nil];
|
| + } else {
|
| + transform();
|
| + }
|
| +}
|
| +
|
| +- (void)hideLogoAnimated:(BOOL)animated {
|
| + if ([_logo iris] == 0.0)
|
| + return;
|
| +
|
| + void (^transform)(void) = ^{
|
| + [_logo setIris:0.0];
|
| + };
|
| + if (animated) {
|
| + [UIView cr_animateWithDuration:kLogoIrisAnimationDuration
|
| + delay:0
|
| + curve:ios::material::CurveEaseOut
|
| + options:0
|
| + animations:transform
|
| + completion:nil];
|
| + } else {
|
| + transform();
|
| + }
|
| +}
|
| +
|
| +@end
|
|
|