chromium/ios/chrome/browser/autofill/ui_bundled/form_input_accessory/form_suggestion_view.mm

// Copyright 2013 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/autofill/ui_bundled/form_input_accessory/form_suggestion_view.h"

#import "base/apple/foundation_util.h"
#import "base/check.h"
#import "base/i18n/rtl.h"
#import "base/metrics/histogram_functions.h"
#import "base/metrics/user_metrics.h"
#import "base/strings/strcat.h"
#import "components/autofill/core/browser/filling_product.h"
#import "components/autofill/core/browser/ui/suggestion_type.h"
#import "components/autofill/ios/browser/form_suggestion.h"
#import "ios/chrome/browser/autofill/model/form_suggestion_client.h"
#import "ios/chrome/browser/autofill/model/form_suggestion_constants.h"
#import "ios/chrome/browser/autofill/ui_bundled/form_input_accessory/form_suggestion_label.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/shared/ui/util/layout_guide_names.h"
#import "ios/chrome/browser/shared/ui/util/rtl_geometry.h"
#import "ios/chrome/browser/shared/ui/util/util_swift.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"

using autofill::FillingProduct;
using autofill::SuggestionType;
using base::UmaHistogramSparse;

namespace {

// Vertical margin between suggestions and the edge of the suggestion content
// frame.
constexpr CGFloat kSuggestionVerticalMargin = 6;

// Horizontal margin around suggestions (i.e. between suggestions, and between
// the end suggestions and the suggestion content frame).
constexpr CGFloat kSuggestionHorizontalMargin = 6;

// Horizontal space between suggestions.
constexpr CGFloat kSpacing = 4;

// Horizontal margin at the end of the last suggestion.
constexpr CGFloat kSuggestionEndHorizontalMargin = 10;

// Initial spacing between suggestion chips at the beginning of the scroll hint
// animation.
constexpr CGFloat kScrollHintInitialSpacing = 50;

// The amount of time (in seconds) the scroll hint animation takes.
constexpr CGFloat kScrollHintDuration = 0.5;

// Logs the right histogram when a suggestion from the keyboard accessory is
// selected. `suggestion_type` is the type of the selected suggestion and
// `index` is the position of the selected position among the available
// suggestions.
void LogSelectedSuggestionIndexMetric(SuggestionType suggestion_type,
                                      NSInteger index) {
  FillingProduct filling_product =
      GetFillingProductFromSuggestionType(suggestion_type);
  std::string filling_product_bucket;
  switch (filling_product) {
    case FillingProduct::kCreditCard:
    case FillingProduct::kIban:
    case FillingProduct::kStandaloneCvc:
    case FillingProduct::kAddress:
    case FillingProduct::kPlusAddresses:
    case FillingProduct::kPassword:
    case FillingProduct::kNone:
    case FillingProduct::kAutocomplete:
      filling_product_bucket = FillingProductToString(filling_product);
      break;
    case FillingProduct::kCompose:
    case FillingProduct::kPredictionImprovements:
    case FillingProduct::kMerchantPromoCode:
      // These cases are currently not available on iOS.
      NOTREACHED_NORETURN();
  }
  UmaHistogramSparse(
      base::StrCat({"Autofill.UserAcceptedSuggestionAtIndex.",
                    filling_product_bucket, ".KeyboardAccessory"}),
      index);
}

}  // namespace

@interface FormSuggestionView () <FormSuggestionLabelDelegate,
                                  UIScrollViewDelegate>

// The FormSuggestions that are displayed by this view.
@property(nonatomic) NSArray<FormSuggestion*>* suggestions;

// The stack view with the suggestions.
@property(nonatomic) UIStackView* stackView;

// The view containing accessory buttons at the trailing end,
// after the form suggestion view.
@property(nonatomic, weak) UIView* accessoryTrailingView;

@end

@implementation FormSuggestionView

#pragma mark - Public

- (void)updateSuggestions:(NSArray<FormSuggestion*>*)suggestions
           showScrollHint:(BOOL)showScrollHint
    accessoryTrailingView:(UIView*)accessoryTrailingView
               completion:(void (^)(BOOL finished))completion {
  if ([self.suggestions isEqualToArray:suggestions] && !suggestions.count) {
    if (completion) {
      completion(NO);
    }
    return;
  }
  self.suggestions = [suggestions copy];

  if (!self.stackView) {
    if (completion) {
      completion(NO);
    }
    return;
  }

  for (UIView* view in [self.stackView.arrangedSubviews copy]) {
    [self.stackView removeArrangedSubview:view];
    [view removeFromSuperview];
  }
  self.contentInset = UIEdgeInsetsZero;
  self.accessoryTrailingView = accessoryTrailingView;
  [self createAndInsertArrangedSubviews];
  [self setContentOffset:CGPointZero];
  if (showScrollHint) {
    [self scrollHint:completion];
  } else if (completion) {
    completion(NO);
  }
}

- (void)resetContentInsetAndDelegateAnimated:(BOOL)animated {
  self.delegate = nil;
  __weak __typeof(self) weakSelf = self;
  [UIView animateWithDuration:animated ? 0.2 : 0.0
                   animations:^{
                     weakSelf.contentInset = UIEdgeInsetsZero;
                   }];
}

- (void)lockTrailingView {
  if (!self.superview || !self.trailingView) {
    return;
  }
  LayoutOffset layoutOffset = CGRectGetLeadingLayoutOffsetInBoundingRect(
      self.trailingView.frame, {CGPointZero, self.contentSize});
  // Because the way the scroll view is transformed for RTL, the insets don't
  // need to be directed.
  UIEdgeInsets lockedContentInsets = UIEdgeInsetsMake(0, -layoutOffset, 0, 0);
  __weak __typeof(self) weakSelf = self;
  [UIView animateWithDuration:0.2
      animations:^{
        weakSelf.contentInset = lockedContentInsets;
      }
      completion:^(BOOL finished) {
        if (!IsKeyboardAccessoryUpgradeEnabled()) {
          weakSelf.delegate = weakSelf;
        }
      }];
}

#pragma mark - UIView

- (void)willMoveToSuperview:(UIView*)newSuperview {
  // Create and add subviews the first time this moves to a superview.
  if (newSuperview && self.subviews.count == 0) {
    [self setupSubviews];
  }
  [super willMoveToSuperview:newSuperview];
}

#pragma mark - FormSuggestionLabelDelegate

- (void)didTapFormSuggestionLabel:(FormSuggestionLabel*)formSuggestionLabel {
  NSUInteger index =
      [self.stackView.arrangedSubviews indexOfObject:formSuggestionLabel];
  DCHECK(index != NSNotFound);
  FormSuggestion* suggestion = [self.suggestions objectAtIndex:index];
  LogSelectedSuggestionIndexMetric(suggestion.type, index);
  base::RecordAction(
      base::UserMetricsAction("KeyboardAccessory_SuggestionAccepted"));
  [self.formSuggestionViewDelegate formSuggestionView:self
                                  didAcceptSuggestion:suggestion
                                              atIndex:index];
}

#pragma mark - Helper methods

// Creates and adds subviews.
- (void)setupSubviews {
  self.showsVerticalScrollIndicator = NO;
  self.showsHorizontalScrollIndicator = NO;
  self.canCancelContentTouches = YES;
  self.alwaysBounceHorizontal = YES;

  UIStackView* stackView = [[UIStackView alloc] initWithArrangedSubviews:@[]];
  stackView.axis = UILayoutConstraintAxisHorizontal;
  stackView.layoutMarginsRelativeArrangement = YES;
  stackView.layoutMargins = UIEdgeInsetsMake(
      kSuggestionVerticalMargin, kSuggestionHorizontalMargin,
      kSuggestionVerticalMargin,
      IsKeyboardAccessoryUpgradeEnabled() ? kSuggestionEndHorizontalMargin
                                          : kSuggestionHorizontalMargin);
  stackView.spacing = IsKeyboardAccessoryUpgradeEnabled()
                          ? kSpacing
                          : kSuggestionHorizontalMargin;
  stackView.translatesAutoresizingMaskIntoConstraints = NO;
  [self addSubview:stackView];
  AddSameConstraints(stackView, self);
  [stackView.heightAnchor constraintEqualToAnchor:self.heightAnchor].active =
      true;

  // Rotate the UIScrollView and its UIStackView subview 180 degrees so that the
  // first suggestion actually shows up first.
  if (base::i18n::IsRTL()) {
    self.transform = CGAffineTransformMakeRotation(M_PI);
    stackView.transform = CGAffineTransformMakeRotation(M_PI);
  }
  self.stackView = stackView;
  [self createAndInsertArrangedSubviews];

  self.accessibilityIdentifier = kFormSuggestionsViewAccessibilityIdentifier;
}

- (void)createAndInsertArrangedSubviews {
  auto setupBlock = ^(FormSuggestion* suggestion, NSUInteger idx, BOOL* stop) {
    UIView* label = [[FormSuggestionLabel alloc]
           initWithSuggestion:suggestion
                        index:idx
               numSuggestions:[self.suggestions count]
        accessoryTrailingView:self.accessoryTrailingView
                     delegate:self];
    [self.stackView addArrangedSubview:label];
    if (idx == 0 &&
        suggestion.featureForIPH != SuggestionFeatureForIPH::kUnknown) {
      // Track the first element.
      [self.layoutGuideCenter referenceView:label
                                  underName:kAutofillFirstSuggestionGuide];
    }
  };
  [self.suggestions enumerateObjectsUsingBlock:setupBlock];
  if (self.trailingView) {
    [self.stackView addArrangedSubview:self.trailingView];
  }
}

// Performs the scroll hint. This is triggered when the keyboard accessory
// initially receives suggestions.
- (void)scrollHint:(void (^)(BOOL finished))completion {
  if (!IsKeyboardAccessoryUpgradeEnabled() ||
      !self.stackView.arrangedSubviews.count) {
    if (completion) {
      completion(NO);
    }
    return;
  }

  // Make sure all the subview layouts are done before computing frame offsets.
  for (UIView* view in self.stackView.arrangedSubviews) {
    [view layoutIfNeeded];
  }

  // This creates an animation where suggestions fly in from the right.
  [self spaceSubviews:kScrollHintInitialSpacing alpha:0];
  __weak __typeof(self) weakSelf = self;
  [UIView animateWithDuration:kScrollHintDuration
                   animations:^{
                     [weakSelf spaceSubviews:-kScrollHintInitialSpacing
                                       alpha:1];
                   }
                   completion:completion];
}

// Inserts spacing between suggestion views and sets their transparency.
- (void)spaceSubviews:(CGFloat)spacing alpha:(CGFloat)alpha {
  CGFloat offset = spacing;
  for (UIView* view in self.stackView.arrangedSubviews) {
    CGRect frame = view.frame;
    frame.origin.x += offset;
    view.frame = frame;
    view.alpha = alpha;
    offset += spacing;
  }
}

#pragma mark - Setters

- (void)setTrailingView:(UIView*)subview {
  if (_trailingView.superview) {
    [_stackView removeArrangedSubview:_trailingView];
    [_trailingView removeFromSuperview];
  }
  _trailingView = subview;
  if (_stackView) {
    [_stackView addArrangedSubview:_trailingView];
  }
}

#pragma mark - UIScrollViewDelegate

- (void)scrollViewDidScroll:(UIScrollView*)scrollView {
  DCHECK(!IsKeyboardAccessoryUpgradeEnabled());

  CGFloat offset = self.contentOffset.x;
  CGFloat inset = self.contentInset.left;  // Inset is negative when locked.
  CGFloat diff = offset + inset;
  if (diff < -55) {
    [self.formSuggestionViewDelegate
        formSuggestionViewShouldResetFromPull:self];
  }
}

@end