blob: 45f471517df50ee1821bb84933f503ef94f1ea4f [file] [log] [blame]
// 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/omnibox/ui/omnibox_view_controller.h"
#import "base/containers/contains.h"
#import "base/functional/bind.h"
#import "base/memory/raw_ptr.h"
#import "base/metrics/user_metrics.h"
#import "base/metrics/user_metrics_action.h"
#import "base/strings/sys_string_conversions.h"
#import "components/omnibox/browser/omnibox_field_trial.h"
#import "components/omnibox/common/omnibox_features.h"
#import "components/open_from_clipboard/clipboard_recent_content.h"
#import "components/strings/grit/components_strings.h"
#import "ios/chrome/browser/omnibox/public/omnibox_constants.h"
#import "ios/chrome/browser/omnibox/public/omnibox_ui_features.h"
#import "ios/chrome/browser/omnibox/ui/omnibox_container_view.h"
#import "ios/chrome/browser/omnibox/ui/omnibox_keyboard_delegate.h"
#import "ios/chrome/browser/omnibox/ui/omnibox_mutator.h"
#import "ios/chrome/browser/omnibox/ui/omnibox_text_field_delegate.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/toolbar/ui_bundled/public/toolbar_constants.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ios/public/provider/chrome/browser/lens/lens_api.h"
#import "ui/base/l10n/l10n_util.h"
#import "ui/base/l10n/l10n_util_mac.h"
using base::UserMetricsAction;
@interface OmniboxViewController () <OmniboxTextFieldDelegate,
OmniboxKeyboardDelegate,
UIScribbleInteractionDelegate>
// Override of UIViewController's view with a different type.
@property(nonatomic, strong) OmniboxContainerView* view;
// Whether the default search engine supports search-by-image. This controls the
// edit menu option to do an image search.
@property(nonatomic, assign) BOOL searchByImageEnabled;
// Whether the default search engine supports Lens. This controls the
// edit menu option to do a Lens search.
@property(nonatomic, assign) BOOL lensImageEnabled;
/// The placeholder text used in normal mode.
@property(nonatomic, copy) NSString* searchOrTypeURLPlaceholderText;
/// The placeholder text used in search-only mode.
@property(nonatomic, copy) NSString* searchOnlyPlaceholderText;
// YES if we are already forwarding an textDidChangeWithUserEvent message to the
// omnibox text controller. Needed to prevent infinite recursion.
// TODO(crbug.com/40103694): There must be a better way.
@property(nonatomic, assign) BOOL forwardingOnDidChange;
// YES if this text field is currently processing a user-initiated event,
// such as typing in the omnibox or pressing the clear button. Used to
// distinguish between calls to textDidChange that are triggered by the user
// typing vs by calls to setText.
@property(nonatomic, assign) BOOL processingUserEvent;
// A flag that is set whenever any input or copy/paste event happened in the
// omnibox while it was focused. Used to count event "user focuses the omnibox
// to view the complete URL and immediately defocuses it".
@property(nonatomic, assign) BOOL omniboxInteractedWhileFocused;
// Stores whether the clipboard currently stores copied content.
@property(nonatomic, assign) BOOL hasCopiedContent;
// Stores the current content type in the clipboard. This is only valid if
// `hasCopiedContent` is YES.
@property(nonatomic, assign) ClipboardContentType copiedContentType;
// Stores whether the cached clipboard state is currently being updated. See
// `-updateCachedClipboardState` for more information.
@property(nonatomic, assign) BOOL isUpdatingCachedClipboardState;
@end
@implementation OmniboxViewController {
// Omnibox uses a custom clear button. It has a custom tint and image, but
// otherwise it should act exactly like a system button.
/// Clear button owned by `view` (OmniboxContainerView).
__weak UIButton* _clearButton;
/// Whether the view is presented in the lens overlay.
BOOL _isLensOverlay;
}
@dynamic view;
- (instancetype)initWithIsLensOverlay:(BOOL)isLensOverlay {
self = [super initWithNibName:nil bundle:nil];
if (self) {
_isLensOverlay = isLensOverlay;
}
return self;
}
#pragma mark - UIViewController
- (void)loadView {
UIColor* textColor = [UIColor colorNamed:kTextPrimaryColor];
UIColor* textFieldTintColor = [UIColor colorNamed:kBlueColor];
UIColor* iconTintColor;
iconTintColor = [UIColor colorNamed:kToolbarButtonColor];
self.view = [[OmniboxContainerView alloc] initWithFrame:CGRectZero
textColor:textColor
textFieldTint:textFieldTintColor
iconTint:iconTintColor
isLensOverlay:_isLensOverlay];
self.view.layoutGuideCenter = self.layoutGuideCenter;
_clearButton = self.view.clearButton;
self.view.shouldGroupAccessibilityChildren = YES;
self.textField.delegate = self;
self.textField.omniboxKeyboardDelegate = self;
SetA11yLabelAndUiAutomationName(self.textField, IDS_ACCNAME_LOCATION,
@"Address");
[self.textField
addInteraction:[[UIScribbleInteraction alloc] initWithDelegate:self]];
}
- (void)viewDidLoad {
[super viewDidLoad];
self.textField.placeholder = [self currentPlaceholderText];
[_clearButton addTarget:self
action:@selector(clearButtonPressed)
forControlEvents:UIControlEventTouchUpInside];
// Observe text changes to show the clear button when there is text and hide
// it when the textfield is empty.
[self.textField addTarget:self
action:@selector(textFieldDidChange:)
forControlEvents:UIControlEventEditingChanged];
if (base::FeatureList::IsEnabled(kEnableLensOverlay)) {
[self.view.thumbnailButton addTarget:self
action:@selector(didTapThumbnailButton)
forControlEvents:UIControlEventTouchUpInside];
}
[NSNotificationCenter.defaultCenter
addObserver:self
selector:@selector(textInputModeDidChange)
name:UITextInputCurrentInputModeDidChangeNotification
object:nil];
// Reset the text after initial layout has been forced, see comment in
// `OmniboxTextFieldIOS`.
if ([self.textField.text isEqualToString:@" "]) {
self.textField.text = @"";
}
[self updateClearButtonVisibility];
[self updateLeadingImage];
}
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
[NSNotificationCenter.defaultCenter
addObserver:self
selector:@selector(pasteboardDidChange:)
name:UIPasteboardChangedNotification
object:nil];
// The pasteboard changed notification doesn't fire if the clipboard changes
// while the app is in the background, so update the state whenever the app
// becomes active.
[NSNotificationCenter.defaultCenter
addObserver:self
selector:@selector(applicationDidBecomeActive:)
name:UIApplicationDidBecomeActiveNotification
object:nil];
}
- (void)viewIsAppearing:(BOOL)animated {
[super viewIsAppearing:animated];
if (_isLensOverlay) {
self.semanticContentAttribute =
[self.textField bestSemanticContentAttribute];
[self.textField updateTextDirection];
}
}
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
self.textField.selectedTextRange =
[self.textField textRangeFromPosition:self.textField.beginningOfDocument
toPosition:self.textField.beginningOfDocument];
[NSNotificationCenter.defaultCenter
removeObserver:self
name:UIPasteboardChangedNotification
object:nil];
// The pasteboard changed notification doesn't fire if the clipboard changes
// while the app is in the background, so update the state whenever the app
// becomes active.
[NSNotificationCenter.defaultCenter
removeObserver:self
name:UIApplicationDidBecomeActiveNotification
object:nil];
}
#pragma mark - properties
- (UIView<TextFieldViewContaining>*)viewContainingTextField {
return self.view;
}
#pragma mark - public methods
- (OmniboxTextFieldIOS*)textField {
return self.view.textField;
}
- (void)prepareOmniboxForScribble {
[self.mutator prepareForScribble];
self.textField.placeholder = nil;
}
- (void)cleanupOmniboxAfterScribble {
[self.mutator cleanupAfterScribble];
self.textField.placeholder = [self currentPlaceholderText];
}
#pragma mark - OmniboxTextFieldDelegate
#pragma mark UITextFieldDelegate
- (BOOL)textField:(UITextField*)textField
shouldChangeCharactersInRange:(NSRange)range
replacementString:(NSString*)newText {
// Any change in the content of the omnibox should deselect thumbnail button.
self.view.thumbnailButton.selected = NO;
self.processingUserEvent =
[self.mutator shouldChangeCharactersInRange:range
replacementString:newText];
return self.processingUserEvent;
}
- (void)textFieldDidChange:(id)sender {
[self updateLeadingImage];
[self updateClearButtonVisibility];
self.semanticContentAttribute = [self.textField bestSemanticContentAttribute];
if (self.forwardingOnDidChange) {
return;
}
// Reset the changed flag.
self.omniboxInteractedWhileFocused = YES;
BOOL savedProcessingUserEvent = self.processingUserEvent;
self.processingUserEvent = NO;
self.forwardingOnDidChange = YES;
[self.mutator textDidChangeWithUserEvent:savedProcessingUserEvent];
self.forwardingOnDidChange = NO;
}
- (BOOL)textFieldShouldReturn:(UITextField*)textField {
// Forward kReturnKey action to the keyboard handler.
if ([self canPerformKeyboardAction:OmniboxKeyboardAction::kReturnKey]) {
[self performKeyboardAction:OmniboxKeyboardAction::kReturnKey];
return NO;
}
return YES;
}
// Always update the text field colors when we start editing. It's possible
// for this method to be called when we are already editing (popup focus
// change). In this case, OnDidBeginEditing will be called multiple times.
// If that becomes an issue a boolean should be added to track editing state.
- (void)textFieldDidBeginEditing:(UITextField*)textField {
[self updateCachedClipboardState];
// Update the clear button state.
[self updateClearButtonVisibility];
[self updateLeadingImage];
if (base::FeatureList::IsEnabled(kEnableLensOverlay)) {
self.view.thumbnailButton.selected = NO;
}
self.semanticContentAttribute = [self.textField bestSemanticContentAttribute];
self.omniboxInteractedWhileFocused = NO;
[self.mutator onDidBeginEditing];
}
// Records the metrics as needed.
- (void)textFieldDidEndEditing:(UITextField*)textField
reason:(UITextFieldDidEndEditingReason)reason {
if (base::FeatureList::IsEnabled(kEnableLensOverlay)) {
self.view.thumbnailButton.selected = NO;
}
if (!self.omniboxInteractedWhileFocused) {
RecordAction(
UserMetricsAction("Mobile_FocusedDefocusedOmnibox_WithNoAction"));
}
}
- (UIMenu*)textField:(UITextField*)textField
editMenuForCharactersInRange:(NSRange)range
suggestedActions:(NSArray<UIMenuElement*>*)suggestedActions {
NSMutableArray* actions = [suggestedActions mutableCopy];
if ([self canPerformAction:@selector(searchCopiedImage:) withSender:nil]) {
UIAction* searchCopiedImage = [UIAction
actionWithTitle:l10n_util::GetNSString(IDS_IOS_SEARCH_COPIED_IMAGE)
image:nil
identifier:nil
handler:^(__kindof UIAction* action) {
[self searchCopiedImage:nil];
}];
[actions addObject:searchCopiedImage];
}
if ([self canPerformAction:@selector(lensCopiedImage:) withSender:nil]) {
UIAction* searchCopiedImageWithLens =
[UIAction actionWithTitle:l10n_util::GetNSString(
IDS_IOS_SEARCH_COPIED_IMAGE_WITH_LENS)
image:nil
identifier:nil
handler:^(__kindof UIAction* action) {
[self lensCopiedImage:nil];
}];
[actions addObject:searchCopiedImageWithLens];
}
if ([self canPerformAction:@selector(visitCopiedLink:) withSender:nil]) {
UIAction* visitCopiedLink = [UIAction
actionWithTitle:l10n_util::GetNSString(IDS_IOS_VISIT_COPIED_LINK)
image:nil
identifier:nil
handler:^(__kindof UIAction* action) {
[self visitCopiedLink:nil];
}];
[actions addObject:visitCopiedLink];
}
if ([self canPerformAction:@selector(searchCopiedText:) withSender:nil]) {
UIAction* searchCopiedText = [UIAction
actionWithTitle:l10n_util::GetNSString(IDS_IOS_SEARCH_COPIED_TEXT)
image:nil
identifier:nil
handler:^(__kindof UIAction* action) {
[self searchCopiedText:nil];
}];
[actions addObject:searchCopiedText];
}
return [UIMenu menuWithChildren:actions];
}
#pragma mark OmniboxTextFieldDelegate
- (void)onCopy {
self.omniboxInteractedWhileFocused = YES;
[self.mutator onCopy];
}
- (void)willPaste {
[self.mutator willPaste];
}
- (void)onDeleteBackward {
// If not in pre-edit, deleting when cursor is at the beginning interacts with
// the thumbnail.
if (OmniboxTextFieldIOS* textField = self.textField;
!textField.isPreEditing && textField.selectedTextRange.empty &&
[textField offsetFromPosition:textField.beginningOfDocument
toPosition:textField.selectedTextRange.start] == 0) {
[self didTapThumbnailButton];
}
[self.mutator onDeleteBackward];
}
- (void)textFieldDidAcceptAutocomplete:(OmniboxTextFieldIOS*)textField {
[self.mutator onAcceptAutocomplete];
}
- (void)textFieldDidRemoveAdditionalText:(OmniboxTextFieldIOS*)textField {
base::RecordAction(UserMetricsAction("MobileOmniboxRichInlineRemoved"));
[self.mutator removeAdditionalText];
}
- (BOOL)canPasteItemProviders:(NSArray<NSItemProvider*>*)itemProviders {
for (NSItemProvider* itemProvider in itemProviders) {
if (((self.searchByImageEnabled || self.shouldUseLensInMenu) &&
[itemProvider canLoadObjectOfClass:[UIImage class]]) ||
[itemProvider canLoadObjectOfClass:[NSURL class]] ||
[itemProvider canLoadObjectOfClass:[NSString class]]) {
return YES;
}
}
return NO;
}
- (void)pasteItemProviders:(NSArray<NSItemProvider*>*)itemProviders {
// Interacted while focused.
self.omniboxInteractedWhileFocused = YES;
[self.mutator pasteToSearch:itemProviders];
}
- (void)textFieldDidAcceptInput:(OmniboxTextFieldIOS*)textField {
[self.mutator acceptInput];
}
#pragma mark - OmniboxKeyboardDelegate
- (BOOL)canPerformKeyboardAction:(OmniboxKeyboardAction)keyboardAction {
return [self.popupKeyboardDelegate canPerformKeyboardAction:keyboardAction] ||
[self.textField canPerformKeyboardAction:keyboardAction];
}
- (void)performKeyboardAction:(OmniboxKeyboardAction)keyboardAction {
if ([self.popupKeyboardDelegate canPerformKeyboardAction:keyboardAction]) {
[self.popupKeyboardDelegate performKeyboardAction:keyboardAction];
} else if ([self.textField canPerformKeyboardAction:keyboardAction]) {
[self.textField performKeyboardAction:keyboardAction];
} else {
NOTREACHED() << "Check canPerformKeyboardAction before!";
}
}
#pragma mark - OmniboxConsumer
- (void)updateAutocompleteIcon:(UIImage*)icon
withAccessibilityIdentifier:(NSString*)accessibilityIdentifier {
[self.view setLeadingImage:icon
withAccessibilityIdentifier:accessibilityIdentifier];
}
- (void)updateSearchByImageSupported:(BOOL)searchByImageSupported {
self.searchByImageEnabled = searchByImageSupported;
}
- (void)updateLensImageSupported:(BOOL)lensImageSupported {
self.lensImageEnabled = lensImageSupported;
}
- (void)setThumbnailImage:(UIImage*)image {
[self.view setThumbnailImage:image];
// Cancel any pending image removal if a new selection is made.
self.view.thumbnailButton.selected = NO;
self.textField.placeholder = [self currentPlaceholderText];
[self updateReturnKeyAvailability];
}
- (void)updateReturnKeyAvailability {
self.textField.allowsReturnKeyWithEmptyText =
!!self.view.thumbnailImage ||
[self.popupKeyboardDelegate
canPerformKeyboardAction:OmniboxKeyboardAction::kReturnKey];
}
- (void)setPlaceholderText:(NSString*)placeholderText {
if (_searchOrTypeURLPlaceholderText == placeholderText) {
return;
}
_searchOrTypeURLPlaceholderText = [placeholderText copy];
self.textField.placeholder = [self currentPlaceholderText];
}
- (void)setSearchOnlyPlaceholderText:(NSString*)placeholderText {
if (_searchOnlyPlaceholderText == placeholderText) {
return;
}
_searchOnlyPlaceholderText = [placeholderText copy];
self.textField.placeholder = [self currentPlaceholderText];
}
#pragma mark - EditViewAnimatee
- (void)setLeadingIconScale:(CGFloat)scale {
[self.view setLeadingImageScale:scale];
}
- (void)setClearButtonFaded:(BOOL)faded {
_clearButton.alpha = faded ? 0 : 1;
}
#pragma mark - LocationBarOffsetProvider
- (CGFloat)xOffsetForString:(NSString*)string {
return [self.textField offsetForString:string];
}
#pragma mark - private
- (void)updateLeadingImage {
UIImage* image = self.textField.text.length ? self.defaultLeadingImage
: self.emptyTextLeadingImage;
NSString* accessibilityID =
self.textField.text.length
? kOmniboxLeadingImageDefaultAccessibilityIdentifier
: kOmniboxLeadingImageEmptyTextAccessibilityIdentifier;
[self.view setLeadingImage:image withAccessibilityIdentifier:accessibilityID];
}
- (BOOL)shouldUseLensInMenu {
return ios::provider::IsLensSupported() &&
base::FeatureList::IsEnabled(kEnableLensInOmniboxCopiedImage) &&
self.lensImageEnabled;
}
- (void)onClipboardContentTypesReceived:
(const std::set<ClipboardContentType>&)types {
self.hasCopiedContent = !types.empty();
if ((self.searchByImageEnabled || self.shouldUseLensInMenu) &&
base::Contains(types, ClipboardContentType::Image)) {
self.copiedContentType = ClipboardContentType::Image;
} else if (base::Contains(types, ClipboardContentType::URL)) {
self.copiedContentType = ClipboardContentType::URL;
} else if (base::Contains(types, ClipboardContentType::Text)) {
self.copiedContentType = ClipboardContentType::Text;
}
self.isUpdatingCachedClipboardState = NO;
}
#pragma mark notification callbacks
// Called on UITextInputCurrentInputModeDidChangeNotification for self.textField
- (void)textInputModeDidChange {
// Only respond to language changes when the omnibox is first responder.
if (![self.textField isFirstResponder]) {
return;
}
[self.textField updateTextDirection];
self.semanticContentAttribute = [self.textField bestSemanticContentAttribute];
[self.mutator onTextInputModeChange];
}
- (void)updateCachedClipboardState {
// Sometimes, checking the clipboard state itself causes the clipboard to
// emit a UIPasteboardChangedNotification, leading to an infinite loop. For
// now, just prevent re-checking the clipboard state, but hopefully this will
// be fixed in a future iOS version (see crbug.com/1049053 for crash details).
if (self.isUpdatingCachedClipboardState) {
return;
}
self.isUpdatingCachedClipboardState = YES;
self.hasCopiedContent = NO;
ClipboardRecentContent* clipboardRecentContent =
ClipboardRecentContent::GetInstance();
std::set<ClipboardContentType> desired_types;
desired_types.insert(ClipboardContentType::URL);
desired_types.insert(ClipboardContentType::Text);
desired_types.insert(ClipboardContentType::Image);
__weak __typeof(self) weakSelf = self;
clipboardRecentContent->HasRecentContentFromClipboard(
desired_types,
base::BindOnce(^(std::set<ClipboardContentType> matched_types) {
[weakSelf onClipboardContentTypesReceived:matched_types];
}));
}
- (void)pasteboardDidChange:(NSNotification*)notification {
[self updateCachedClipboardState];
}
- (void)applicationDidBecomeActive:(NSNotification*)notification {
[self updateCachedClipboardState];
}
#pragma mark clear button
- (void)clearButtonPressed {
[self.mutator clearText];
[self updateClearButtonVisibility];
[self updateLeadingImage];
}
// Hides the clear button if the textfield is empty; shows it otherwise.
- (void)updateClearButtonVisibility {
BOOL hasText = self.textField.text.length > 0;
[self.view setClearButtonHidden:!hasText];
}
// Handle the updates to semanticContentAttribute by passing the changes along
// to the necessary views.
- (void)setSemanticContentAttribute:
(UISemanticContentAttribute)semanticContentAttribute {
_semanticContentAttribute = semanticContentAttribute;
self.view.semanticContentAttribute = self.semanticContentAttribute;
self.textField.semanticContentAttribute = self.semanticContentAttribute;
}
#pragma mark - UIMenuItem
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
if (action == @selector(searchCopiedImage:) ||
action == @selector(lensCopiedImage:) ||
action == @selector(visitCopiedLink:) ||
action == @selector(searchCopiedText:)) {
if (!self.hasCopiedContent) {
return NO;
}
if (self.copiedContentType == ClipboardContentType::Image) {
if (self.shouldUseLensInMenu) {
return action == @selector(lensCopiedImage:);
}
return action == @selector(searchCopiedImage:);
}
if (self.copiedContentType == ClipboardContentType::URL) {
return action == @selector(visitCopiedLink:);
}
if (self.copiedContentType == ClipboardContentType::Text) {
return action == @selector(searchCopiedText:);
}
return NO;
}
return NO;
}
- (void)searchCopiedImage:(id)sender {
RecordAction(
UserMetricsAction("Mobile.OmniboxContextMenu.SearchCopiedImage"));
self.omniboxInteractedWhileFocused = YES;
[self.mutator searchCopiedImage];
}
- (void)lensCopiedImage:(id)sender {
RecordAction(UserMetricsAction("Mobile.OmniboxContextMenu.LensCopiedImage"));
self.omniboxInteractedWhileFocused = YES;
[self.mutator lensCopiedImage];
}
- (void)visitCopiedLink:(id)sender {
RecordAction(UserMetricsAction("Mobile.OmniboxContextMenu.VisitCopiedLink"));
self.omniboxInteractedWhileFocused = YES;
[self.mutator visitCopiedLink];
}
- (void)searchCopiedText:(id)sender {
RecordAction(UserMetricsAction("Mobile.OmniboxContextMenu.SearchCopiedText"));
self.omniboxInteractedWhileFocused = YES;
[self.mutator searchCopiedText];
}
#pragma mark - UIScribbleInteractionDelegate
- (void)scribbleInteractionWillBeginWriting:
(UIScribbleInteraction*)interaction {
[self.mutator prepareForScribble];
}
- (void)scribbleInteractionDidFinishWriting:
(UIScribbleInteraction*)interaction {
[self cleanupOmniboxAfterScribble];
}
/// Handles interaction with the thumbnail button. (tap or keyboard delete)
- (void)didTapThumbnailButton {
if (!self.view.thumbnailButton.selected &&
!self.view.thumbnailButton.accessibilityElementIsFocused) {
self.view.thumbnailButton.selected = YES;
} else {
[self.mutator removeThumbnail];
// Clear the selection once it's no longer needed. This prevents it from
// reappearing unexpectedly as the user navigates back through previous
// results.
self.view.thumbnailButton.selected = NO;
}
}
/// Returns the placeholder text for the current state.
- (NSString*)currentPlaceholderText {
if (!base::FeatureList::IsEnabled(kEnableLensOverlay)) {
return self.searchOrTypeURLPlaceholderText;
}
if (self.view.thumbnailImage) {
return l10n_util::GetNSString(IDS_IOS_OMNIBOX_PLACEHOLDER_IMAGE_SEARCH);
} else if (self.searchOnlyUI) {
return self.searchOnlyPlaceholderText;
} else {
return self.searchOrTypeURLPlaceholderText;
}
}
@end