// Copyright 2022 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/omnibox/popup/carousel/omnibox_popup_carousel_cell.h"
#import "base/check.h"
#import "base/i18n/rtl.h"
#import "base/notreached.h"
#import "ios/chrome/browser/ui/omnibox/omnibox_ui_features.h"
#import "ios/chrome/browser/ui/omnibox/popup/carousel/carousel_item.h"
#import "ios/chrome/browser/ui/omnibox/popup/carousel/omnibox_popup_carousel_control.h"
#import "ios/chrome/browser/ui/omnibox/popup/omnibox_popup_accessibility_identifier_constants.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"
#import "ui/base/device_form_factor.h"
namespace {
/// Maximum number of item in the Carousel.
const NSUInteger kCarouselCapacity = 10;
/// Margin of the StackView.
const CGFloat kStackMargin = 8.0f;
/// Leading margin of the StackView.
const CGFloat kStackLeadingMargin = 16.0f;
/// Minimum spacing between items in the StackView.
const CGFloat kMinStackSpacing = 8.0f;
UIColor* CarouselBackgroundColor() {
if (ui::GetDeviceFormFactor() == ui::DEVICE_FORM_FACTOR_TABLET) {
return [UIColor colorNamed:kPrimaryBackgroundColor];
}
return [UIColor colorNamed:kGroupedSecondaryBackgroundColor];
}
/// Horizontal UIScrollView used in OmniboxPopupCarouselCell.
UIScrollView* CarouselScrollView() {
UIScrollView* scrollView = [[UIScrollView alloc] init];
scrollView.translatesAutoresizingMaskIntoConstraints = NO;
scrollView.showsVerticalScrollIndicator = NO;
scrollView.showsHorizontalScrollIndicator = NO;
scrollView.backgroundColor = CarouselBackgroundColor();
return scrollView;
}
/// Horizontal UIStackView used in OmniboxPopupCarouselCell.
UIStackView* CarouselStackView() {
UIStackView* stackView = [[UIStackView alloc] init];
stackView.translatesAutoresizingMaskIntoConstraints = NO;
stackView.axis = UILayoutConstraintAxisHorizontal;
stackView.alignment = UIStackViewAlignmentTop;
stackView.distribution = UIStackViewDistributionEqualSpacing;
stackView.spacing = kMinStackSpacing;
stackView.backgroundColor = CarouselBackgroundColor();
return stackView;
}
} // namespace
@interface OmniboxPopupCarouselCell ()
/// Horizontal UIScrollView for the Carousel.
@property(nonatomic, strong) UIScrollView* scrollView;
/// Horizontal UIStackView containing CarouselItems.
@property(nonatomic, strong) UIStackView* suggestionsStackView;
#pragma mark Dynamic Spacing
/// Number of that that can be fully visible. Apply dynamic spacing only when
/// the number of tiles exceeds `visibleTilesCapacity`.
@property(nonatomic, assign) NSInteger visibleTilesCapacity;
/// Spacing between tiles to have half a tile visible on the trailing edge,
/// indicating a scrollable view.
@property(nonatomic, assign) CGFloat dynamicSpacing;
@end
@implementation OmniboxPopupCarouselCell
- (instancetype)initWithStyle:(UITableViewCellStyle)style
reuseIdentifier:(NSString*)reuseIdentifier {
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
if (self) {
_scrollView = CarouselScrollView();
_suggestionsStackView = CarouselStackView();
self.isAccessibilityElement = NO;
self.contentView.isAccessibilityElement = NO;
self.backgroundColor = CarouselBackgroundColor();
self.accessibilityIdentifier = kOmniboxCarouselCellAccessibilityIdentifier;
}
return self;
}
- (void)didMoveToWindow {
if (self.window) {
// Reset scroll to the left edge.
CGRect scrollViewLeft = CGRectMake(0, 0, 1, 1);
[self.scrollView scrollRectToVisible:scrollViewLeft animated:NO];
}
}
- (void)layoutSubviews {
if (base::i18n::IsRTL()) {
self.scrollView.transform = CGAffineTransformMakeRotation(M_PI);
self.suggestionsStackView.transform = CGAffineTransformMakeRotation(M_PI);
}
[self updateDynamicSpacing];
[super layoutSubviews];
}
- (void)addContentSubviews {
[self.contentView addSubview:_scrollView];
[_scrollView addSubview:_suggestionsStackView];
AddSameConstraintsWithInsets(
_suggestionsStackView, _scrollView,
NSDirectionalEdgeInsetsMake(kStackMargin, kStackLeadingMargin,
kStackMargin, kStackMargin));
AddSameCenterConstraints(_scrollView, self.contentView);
[NSLayoutConstraint activateConstraints:@[
[self.contentView.heightAnchor
constraintEqualToAnchor:_scrollView.heightAnchor],
[_scrollView.heightAnchor
constraintEqualToAnchor:_suggestionsStackView.heightAnchor
constant:kStackMargin * 2],
[self.contentView.widthAnchor
constraintEqualToAnchor:_scrollView.widthAnchor]
]];
}
#pragma mark - properties
- (NSUInteger)tileCount {
return self.suggestionsStackView.arrangedSubviews.count;
}
#pragma mark - Accessibility
- (NSArray*)accessibilityElements {
return self.suggestionsStackView.arrangedSubviews;
}
#pragma mark - Public methods
- (void)setupWithCarouselItems:(NSArray<CarouselItem*>*)carouselItems {
DCHECK(carouselItems.count <= kCarouselCapacity);
if (self.contentView.subviews.count == 0) {
[self addContentSubviews];
}
// Remove all previous items from carousel.
while (self.suggestionsStackView.arrangedSubviews.count != 0) {
[self.suggestionsStackView.arrangedSubviews
.firstObject removeFromSuperview];
}
for (CarouselItem* item in carouselItems) {
OmniboxPopupCarouselControl* control = [self newCarouselControl];
[self.suggestionsStackView addArrangedSubview:control];
[control setCarouselItem:item];
}
if (static_cast<NSInteger>(carouselItems.count) > self.visibleTilesCapacity) {
self.suggestionsStackView.spacing = self.dynamicSpacing;
} else {
self.suggestionsStackView.spacing = kMinStackSpacing;
}
}
- (void)updateCarouselItem:(CarouselItem*)carouselItem {
OmniboxPopupCarouselControl* control =
[self controlForCarouselItem:carouselItem];
if (!control) {
return;
}
[control setCarouselItem:carouselItem];
}
- (NSInteger)highlightedTileIndex {
for (OmniboxPopupCarouselControl* control in self.suggestionsStackView
.arrangedSubviews) {
if (control.selected) {
return [self.suggestionsStackView.arrangedSubviews indexOfObject:control];
}
}
return NSNotFound;
}
#pragma mark - CarouselItemConsumer
- (void)deleteCarouselItem:(CarouselItem*)carouselItem {
OmniboxPopupCarouselControl* control =
[self controlForCarouselItem:carouselItem];
if (!control) {
return;
}
[control removeFromSuperview];
// Remove voice over focus to avoid focusing an invisible tile. Focus instead
// on the first tile.
if (self.suggestionsStackView.arrangedSubviews.firstObject) {
UIAccessibilityPostNotification(
UIAccessibilityScreenChangedNotification,
self.suggestionsStackView.arrangedSubviews.firstObject);
}
[self.delegate carouselCellDidChangeItemCount:self];
}
#pragma mark - UITableViewCell
- (BOOL)isHighlighted {
return self.highlightedTileIndex != NSNotFound;
}
- (void)setHighlighted:(BOOL)highlighted animated:(BOOL)animated {
if (animated) {
[UIView animateWithDuration:0.2
animations:^{
[self setHighlighted:highlighted];
}];
} else {
[self setHighlighted:highlighted];
}
}
- (void)setHighlighted:(BOOL)highlighted {
if (self.isHighlighted == highlighted) {
return;
}
if (highlighted) {
[self highlightFirstTile];
} else {
for (OmniboxPopupCarouselControl* control in self.suggestionsStackView
.arrangedSubviews) {
control.selected = NO;
}
}
}
#pragma mark - OmniboxKeyboardDelegate
- (BOOL)canPerformKeyboardAction:(OmniboxKeyboardAction)keyboardAction {
switch (keyboardAction) {
case OmniboxKeyboardActionUpArrow:
return NO;
case OmniboxKeyboardActionDownArrow:
return NO;
case OmniboxKeyboardActionLeftArrow:
return self.isHighlighted;
case OmniboxKeyboardActionRightArrow:
return self.isHighlighted;
}
return NO;
}
- (void)highlightFirstTile {
NSArray<OmniboxPopupCarouselControl*>* allTiles =
self.suggestionsStackView.arrangedSubviews;
allTiles.firstObject.selected = YES;
}
- (void)performKeyboardAction:(OmniboxKeyboardAction)keyboardAction {
// Find and unhighlight the previously highlighted suggestion.
NSInteger prevHighlightedIndex = self.highlightedTileIndex;
if (prevHighlightedIndex == NSNotFound) {
[self highlightFirstTile];
return;
}
NSInteger nextHighlightedIndex = self.highlightedTileIndex;
NSArray<OmniboxPopupCarouselControl*>* allTiles =
self.suggestionsStackView.arrangedSubviews;
OmniboxKeyboardAction nextTileAction = base::i18n::IsRTL()
? OmniboxKeyboardActionLeftArrow
: OmniboxKeyboardActionRightArrow;
OmniboxKeyboardAction previousTileAction =
base::i18n::IsRTL() ? OmniboxKeyboardActionRightArrow
: OmniboxKeyboardActionLeftArrow;
if (keyboardAction == nextTileAction) {
nextHighlightedIndex =
MIN(prevHighlightedIndex + 1, (NSInteger)allTiles.count - 1);
} else if (keyboardAction == previousTileAction) {
nextHighlightedIndex = MAX(prevHighlightedIndex - 1, 0);
} else {
NOTREACHED_IN_MIGRATION();
}
allTiles[prevHighlightedIndex].selected = NO;
allTiles[nextHighlightedIndex].selected = YES;
}
#pragma mark - OmniboxPopupCarouselControlDelegate
- (void)carouselControlDidBecomeFocused:(OmniboxPopupCarouselControl*)control {
CGRect frameInScrollViewCoordinates = [control convertRect:control.bounds
toView:self.scrollView];
CGRect frameWithPadding =
CGRectInset(frameInScrollViewCoordinates, -kMinStackSpacing * 2, 0);
[self.scrollView scrollRectToVisible:frameWithPadding animated:NO];
}
#pragma mark - Private methods
- (void)didTapCarouselControl:(OmniboxPopupCarouselControl*)control {
DCHECK(control.carouselItem);
[self.delegate carouselCell:self didTapCarouselItem:control.carouselItem];
}
// Returns OmniboxPopupCarouselControl containing `carouselItem`.
- (OmniboxPopupCarouselControl*)controlForCarouselItem:
(CarouselItem*)carouselItem {
for (OmniboxPopupCarouselControl* control in self.suggestionsStackView
.arrangedSubviews) {
if (control.carouselItem == carouselItem) {
return control;
}
}
return nil;
}
// Updates `dynamicSpacing` and `visibleTilesCapacity` for carousel dynamic
// spacing.
- (void)updateDynamicSpacing {
CGFloat availableWidth = self.bounds.size.width - 2 * kStackMargin;
CGFloat tileWidth = kOmniboxPopupCarouselControlWidth + kMinStackSpacing / 2;
availableWidth = self.bounds.size.width - kStackLeadingMargin;
tileWidth = kOmniboxPopupCarouselControlWidth + kMinStackSpacing;
CGFloat maxVisibleTiles = availableWidth / tileWidth;
CGFloat nearestHalfTile = maxVisibleTiles - 0.5;
CGFloat nbFullTiles = floor(nearestHalfTile);
CGFloat percentageOfTileToFill = nearestHalfTile - nbFullTiles;
CGFloat extraSpaceToFill = percentageOfTileToFill * tileWidth;
if (nbFullTiles < FLT_EPSILON) {
return;
}
CGFloat extraSpacingPerTile = extraSpaceToFill / nbFullTiles;
self.dynamicSpacing = extraSpacingPerTile + kMinStackSpacing;
self.visibleTilesCapacity = nbFullTiles;
if (static_cast<NSInteger>(self.tileCount) > self.visibleTilesCapacity) {
self.suggestionsStackView.spacing = self.dynamicSpacing;
} else {
self.suggestionsStackView.spacing = kMinStackSpacing;
}
}
- (OmniboxPopupCarouselControl*)newCarouselControl {
OmniboxPopupCarouselControl* control =
[[OmniboxPopupCarouselControl alloc] init];
control.delegate = self;
[control addTarget:self
action:@selector(didTapCarouselControl:)
forControlEvents:UIControlEventTouchUpInside];
control.isAccessibilityElement = YES;
control.menuProvider = self.menuProvider;
return control;
}
@end