Dactyloidae/mobile/ios/Client/Frontend/Browser/SearchViewController.swift
2026-06-26 21:04:09 -07:00

635 lines
27 KiB
Swift

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import UIKit
import Shared
import Storage
import Telemetry
private enum SearchListSection: Int {
case searchSuggestions
case bookmarksAndHistory
static let Count = 2
}
private struct SearchViewControllerUX {
static let SearchEngineScrollViewBackgroundColor = UIColor.white.withAlphaComponent(0.8).cgColor
static let SearchEngineScrollViewBorderColor = UIColor.black.withAlphaComponent(0.2).cgColor
// TODO: This should use ToolbarHeight in BVC. Fix this when we create a shared theming file.
static let EngineButtonHeight: Float = 44
static let EngineButtonWidth = EngineButtonHeight * 1.4
static let EngineButtonBackgroundColor = UIColor.clear.cgColor
static let SearchImage = "search"
static let SearchEngineTopBorderWidth = 0.5
static let SearchImageHeight: Float = 44
static let SearchImageWidth: Float = 24
static let SuggestionBackgroundColor = UIColor(red: 1, green: 1, blue: 1, alpha: 0.8)
static let SuggestionBorderColor = UIConstants.HighlightBlue
static let SuggestionBorderWidth: CGFloat = 1
static let SuggestionCornerRadius: CGFloat = 4
static let SuggestionInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
static let SuggestionMargin: CGFloat = 8
static let SuggestionCellVerticalPadding: CGFloat = 10
static let SuggestionCellMaxRows = 2
static let IconSize: CGFloat = 23
static let FaviconSize: CGFloat = 29
static let IconBorderColor = UIColor(white: 0, alpha: 0.1)
static let IconBorderWidth: CGFloat = 0.5
}
protocol SearchViewControllerDelegate: class {
func searchViewController(_ searchViewController: SearchViewController, didSelectURL url: URL)
func searchViewController(_ searchViewController: SearchViewController, didLongPressSuggestion suggestion: String)
func presentSearchSettingsController()
}
class SearchViewController: SiteTableViewController, KeyboardHelperDelegate, LoaderListener {
var searchDelegate: SearchViewControllerDelegate?
fileprivate let isPrivate: Bool
fileprivate var suggestClient: SearchSuggestClient?
// Views for displaying the bottom scrollable search engine list. searchEngineScrollView is the
// scrollable container; searchEngineScrollViewContent contains the actual set of search engine buttons.
fileprivate let searchEngineScrollView = ButtonScrollView()
fileprivate let searchEngineScrollViewContent = UIView()
fileprivate lazy var bookmarkedBadge: UIImage = {
return UIImage(named: "bookmarked_passive")!
}()
// Cell for the suggestion flow layout. Since heightForHeaderInSection is called *before*
// cellForRowAtIndexPath, we create the cell to find its height before it's added to the table.
fileprivate let suggestionCell = SuggestionCell(style: UITableViewCellStyle.default, reuseIdentifier: nil)
static var userAgent: String?
init(isPrivate: Bool) {
self.isPrivate = isPrivate
super.init(nibName: nil, bundle: nil)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
view.backgroundColor = UIConstants.PanelBackgroundColor
let blur = UIVisualEffectView(effect: UIBlurEffect(style: UIBlurEffectStyle.light))
view.addSubview(blur)
super.viewDidLoad()
KeyboardHelper.defaultHelper.addDelegate(self)
searchEngineScrollView.layer.backgroundColor = SearchViewControllerUX.SearchEngineScrollViewBackgroundColor
searchEngineScrollView.layer.shadowRadius = 0
searchEngineScrollView.layer.shadowOpacity = 100
searchEngineScrollView.layer.shadowOffset = CGSize(width: 0, height: -SearchViewControllerUX.SearchEngineTopBorderWidth)
searchEngineScrollView.layer.shadowColor = SearchViewControllerUX.SearchEngineScrollViewBorderColor
searchEngineScrollView.clipsToBounds = false
searchEngineScrollView.decelerationRate = UIScrollViewDecelerationRateFast
view.addSubview(searchEngineScrollView)
searchEngineScrollViewContent.layer.backgroundColor = UIColor.clear.cgColor
searchEngineScrollView.addSubview(searchEngineScrollViewContent)
layoutTable()
layoutSearchEngineScrollView()
searchEngineScrollViewContent.snp.makeConstraints { make in
make.center.equalTo(self.searchEngineScrollView).priority(10)
//left-align the engines on iphones, center on ipad
if UIScreen.main.traitCollection.horizontalSizeClass == .compact {
make.left.equalTo(self.searchEngineScrollView).priority(1000)
} else {
make.left.greaterThanOrEqualTo(self.searchEngineScrollView).priority(1000)
}
make.right.lessThanOrEqualTo(self.searchEngineScrollView).priority(1000)
make.top.equalTo(self.searchEngineScrollView)
make.bottom.equalTo(self.searchEngineScrollView)
}
blur.snp.makeConstraints { make in
make.edges.equalTo(self.view)
}
suggestionCell.delegate = self
NotificationCenter.default.addObserver(self, selector: #selector(SearchViewController.SELDynamicFontChanged(_:)), name: NotificationDynamicFontChanged, object: nil)
}
func SELDynamicFontChanged(_ notification: Notification) {
guard notification.name == NotificationDynamicFontChanged else { return }
reloadData()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
reloadSearchEngines()
reloadData()
}
fileprivate func layoutSearchEngineScrollView() {
let keyboardHeight = KeyboardHelper.defaultHelper.currentState?.intersectionHeightForView(self.view) ?? 0
searchEngineScrollView.snp.remakeConstraints { make in
make.left.right.equalTo(self.view)
make.bottom.equalTo(self.view).offset(-keyboardHeight)
}
}
var searchEngines: SearchEngines! {
didSet {
suggestClient?.cancelPendingRequest()
// Query and reload the table with new search suggestions.
querySuggestClient()
// Show the default search engine first.
if !isPrivate {
let ua = SearchViewController.userAgent as String! ?? "FxSearch"
suggestClient = SearchSuggestClient(searchEngine: searchEngines.defaultEngine, userAgent: ua)
}
// Reload the footer list of search engines.
reloadSearchEngines()
}
}
fileprivate var quickSearchEngines: [OpenSearchEngine] {
var engines = searchEngines.quickSearchEngines
// If we're not showing search suggestions, the default search engine won't be visible
// at the top of the table. Show it with the others in the bottom search bar.
if isPrivate || !searchEngines.shouldShowSearchSuggestions {
engines?.insert(searchEngines.defaultEngine, at: 0)
}
return engines!
}
var searchQuery: String = "" {
didSet {
// Reload the tableView to show the updated text in each engine.
reloadData()
}
}
override func reloadData() {
querySuggestClient()
}
fileprivate func layoutTable() {
tableView.snp.remakeConstraints { make in
make.top.equalTo(self.view.snp.top)
make.leading.trailing.equalTo(self.view)
make.bottom.equalTo(self.searchEngineScrollView.snp.top)
}
}
fileprivate func reloadSearchEngines() {
searchEngineScrollViewContent.subviews.forEach { $0.removeFromSuperview() }
var leftEdge = searchEngineScrollViewContent.snp.left
//search settings icon
let searchButton = UIButton()
searchButton.setImage(UIImage(named: "quickSearch"), for: UIControlState())
searchButton.imageView?.contentMode = UIViewContentMode.center
searchButton.layer.backgroundColor = SearchViewControllerUX.EngineButtonBackgroundColor
searchButton.addTarget(self, action: #selector(SearchViewController.SELdidClickSearchButton), for: UIControlEvents.touchUpInside)
searchButton.accessibilityLabel = String(format: NSLocalizedString("Search Settings", tableName: "Search", comment: "Label for search settings button."))
searchButton.imageView?.snp.makeConstraints { make in
make.width.height.equalTo(SearchViewControllerUX.SearchImageWidth)
return
}
searchEngineScrollViewContent.addSubview(searchButton)
searchButton.snp.makeConstraints { make in
make.size.equalTo(SearchViewControllerUX.FaviconSize)
//offset the left edge to align with search results
make.left.equalTo(leftEdge).offset(SearchViewControllerUX.SuggestionMargin * 2)
make.top.equalTo(self.searchEngineScrollViewContent).offset(SearchViewControllerUX.SuggestionMargin)
make.bottom.equalTo(self.searchEngineScrollViewContent).offset(-SearchViewControllerUX.SuggestionMargin)
}
//search engines
leftEdge = searchButton.snp.right
for engine in quickSearchEngines {
let engineButton = UIButton()
engineButton.setImage(engine.image, for: UIControlState())
engineButton.imageView?.contentMode = UIViewContentMode.scaleAspectFit
engineButton.layer.backgroundColor = SearchViewControllerUX.EngineButtonBackgroundColor
engineButton.addTarget(self, action: #selector(SearchViewController.SELdidSelectEngine(_:)), for: UIControlEvents.touchUpInside)
engineButton.accessibilityLabel = String(format: NSLocalizedString("%@ search", tableName: "Search", comment: "Label for search engine buttons. The argument corresponds to the name of the search engine."), engine.shortName)
engineButton.imageView?.snp.makeConstraints { make in
make.width.height.equalTo(SearchViewControllerUX.FaviconSize)
return
}
searchEngineScrollViewContent.addSubview(engineButton)
engineButton.snp.makeConstraints { make in
make.width.equalTo(SearchViewControllerUX.EngineButtonWidth)
make.height.equalTo(SearchViewControllerUX.EngineButtonHeight)
make.left.equalTo(leftEdge)
make.top.equalTo(self.searchEngineScrollViewContent)
make.bottom.equalTo(self.searchEngineScrollViewContent)
if engine === self.searchEngines.quickSearchEngines.last {
make.right.equalTo(self.searchEngineScrollViewContent)
}
}
leftEdge = engineButton.snp.right
}
}
func SELdidSelectEngine(_ sender: UIButton) {
// The UIButtons are the same cardinality and order as the array of quick search engines.
// Subtract 1 from index to account for magnifying glass accessory.
guard let index = searchEngineScrollViewContent.subviews.index(of: sender) else {
assertionFailure()
return
}
let engine = quickSearchEngines[index - 1]
guard let url = engine.searchURLForQuery(searchQuery) else {
assertionFailure()
return
}
Telemetry.default.recordSearch(location: .quickSearch, searchEngine: engine.engineID ?? "other")
searchDelegate?.searchViewController(self, didSelectURL: url)
}
func SELdidClickSearchButton() {
self.searchDelegate?.presentSearchSettingsController()
}
func keyboardHelper(_ keyboardHelper: KeyboardHelper, keyboardWillShowWithState state: KeyboardState) {
animateSearchEnginesWithKeyboard(state)
}
func keyboardHelper(_ keyboardHelper: KeyboardHelper, keyboardDidShowWithState state: KeyboardState) {
}
func keyboardHelper(_ keyboardHelper: KeyboardHelper, keyboardWillHideWithState state: KeyboardState) {
animateSearchEnginesWithKeyboard(state)
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
// The height of the suggestions row may change, so call reloadData() to recalculate cell heights.
coordinator.animate(alongsideTransition: { _ in
self.tableView.reloadData()
}, completion: nil)
}
fileprivate func animateSearchEnginesWithKeyboard(_ keyboardState: KeyboardState) {
layoutSearchEngineScrollView()
UIView.animate(withDuration: keyboardState.animationDuration, animations: {
UIView.setAnimationCurve(keyboardState.animationCurve)
self.view.layoutIfNeeded()
})
}
fileprivate func querySuggestClient() {
suggestClient?.cancelPendingRequest()
if searchQuery.isEmpty || !searchEngines.shouldShowSearchSuggestions || searchQuery.looksLikeAURL() {
suggestionCell.suggestions = []
tableView.reloadData()
return
}
suggestClient?.query(searchQuery, callback: { suggestions, error in
if let error = error {
let isSuggestClientError = error.domain == SearchSuggestClientErrorDomain
switch error.code {
case NSURLErrorCancelled where error.domain == NSURLErrorDomain:
// Request was cancelled. Do nothing.
break
case SearchSuggestClientErrorInvalidEngine where isSuggestClientError:
// Engine does not support search suggestions. Do nothing.
break
case SearchSuggestClientErrorInvalidResponse where isSuggestClientError:
print("Error: Invalid search suggestion data")
default:
print("Error: \(error.description)")
}
} else {
self.suggestionCell.suggestions = suggestions!
}
// If there are no suggestions, just use whatever the user typed.
if suggestions?.isEmpty ?? true {
self.suggestionCell.suggestions = [self.searchQuery]
}
// Reload the tableView to show the new list of search suggestions.
self.tableView.reloadData()
})
}
func loader(dataLoaded data: Cursor<Site>) {
self.data = data
tableView.reloadData()
}
func tableView(_ tableView: UITableView, didSelectRowAtIndexPath indexPath: IndexPath) {
let section = SearchListSection(rawValue: indexPath.section)!
if section == SearchListSection.bookmarksAndHistory {
if let site = data[indexPath.row] {
if let url = URL(string: site.url) {
searchDelegate?.searchViewController(self, didSelectURL: url)
UnifiedTelemetry.recordEvent(category: .action, method: .open, object: .bookmark, value: .awesomebarResults)
}
}
}
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
if let currentSection = SearchListSection(rawValue: indexPath.section) {
switch currentSection {
case .searchSuggestions:
// heightForRowAtIndexPath is called *before* the cell is created, so to get the height,
// force a layout pass first.
suggestionCell.layoutIfNeeded()
return suggestionCell.frame.height
default:
return super.tableView(tableView, heightForRowAt: indexPath)
}
}
return 0
}
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return 0
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
switch SearchListSection(rawValue: indexPath.section)! {
case .searchSuggestions:
suggestionCell.imageView?.image = searchEngines.defaultEngine.image
suggestionCell.imageView?.isAccessibilityElement = true
suggestionCell.imageView?.accessibilityLabel = String(format: NSLocalizedString("Search suggestions from %@", tableName: "Search", comment: "Accessibility label for image of default search engine displayed left to the actual search suggestions from the engine. The parameter substituted for \"%@\" is the name of the search engine. E.g.: Search suggestions from Google"), searchEngines.defaultEngine.shortName)
return suggestionCell
case .bookmarksAndHistory:
let cell = super.tableView(tableView, cellForRowAt: indexPath)
if let site = data[indexPath.row] {
if let cell = cell as? TwoLineTableViewCell {
let isBookmark = site.bookmarked ?? false
cell.setLines(site.title, detailText: site.url)
cell.setRightBadge(isBookmark ? self.bookmarkedBadge : nil)
cell.imageView!.layer.borderColor = SearchViewControllerUX.IconBorderColor.cgColor
cell.imageView!.layer.borderWidth = SearchViewControllerUX.IconBorderWidth
cell.imageView?.setIcon(site.icon, forURL: site.tileURL, completed: { (color, url) in
if site.tileURL == url {
cell.imageView?.image = cell.imageView?.image?.createScaled(CGSize(width: SearchViewControllerUX.IconSize, height: SearchViewControllerUX.IconSize))
cell.imageView?.contentMode = .center
cell.imageView?.backgroundColor = color
}
})
}
}
return cell
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch SearchListSection(rawValue: section)! {
case .searchSuggestions:
return searchEngines.shouldShowSearchSuggestions && !searchQuery.looksLikeAURL() && !isPrivate ? 1 : 0
case .bookmarksAndHistory:
return data.count
}
}
func numberOfSectionsInTableView(_ tableView: UITableView) -> Int {
return SearchListSection.Count
}
}
extension SearchViewController: SuggestionCellDelegate {
fileprivate func suggestionCell(_ suggestionCell: SuggestionCell, didSelectSuggestion suggestion: String) {
// Assume that only the default search engine can provide search suggestions.
let engine = searchEngines.defaultEngine
var url = URIFixup.getURL(suggestion)
if url == nil {
url = engine.searchURLForQuery(suggestion)
}
Telemetry.default.recordSearch(location: .suggestion, searchEngine: engine.engineID ?? "other")
if let url = url {
searchDelegate?.searchViewController(self, didSelectURL: url)
}
}
fileprivate func suggestionCell(_ suggestionCell: SuggestionCell, didLongPressSuggestion suggestion: String) {
searchDelegate?.searchViewController(self, didLongPressSuggestion: suggestion)
}
}
/**
* Private extension containing string operations specific to this view controller
*/
fileprivate extension String {
func looksLikeAURL() -> Bool {
// The assumption here is that if the user is typing in a forward slash and there are no spaces
// involved, it's going to be a URL. If we type a space, any url would be invalid.
// See https://bugzilla.mozilla.org/show_bug.cgi?id=1192155 for additional details.
return self.contains("/") && !self.contains(" ")
}
}
/**
* UIScrollView that prevents buttons from interfering with scroll.
*/
fileprivate class ButtonScrollView: UIScrollView {
fileprivate override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
fileprivate protocol SuggestionCellDelegate: class {
func suggestionCell(_ suggestionCell: SuggestionCell, didSelectSuggestion suggestion: String)
func suggestionCell(_ suggestionCell: SuggestionCell, didLongPressSuggestion suggestion: String)
}
/**
* Cell that wraps a list of search suggestion buttons.
*/
fileprivate class SuggestionCell: UITableViewCell {
weak var delegate: SuggestionCellDelegate?
let container = UIView()
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
isAccessibilityElement = false
accessibilityLabel = nil
layoutMargins = UIEdgeInsets.zero
separatorInset = UIEdgeInsets.zero
selectionStyle = UITableViewCellSelectionStyle.none
container.backgroundColor = UIColor.clear
contentView.backgroundColor = UIColor.clear
backgroundColor = UIColor.clear
contentView.addSubview(container)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
var suggestions: [String] = [] {
didSet {
for view in container.subviews {
view.removeFromSuperview()
}
for suggestion in suggestions {
let button = SuggestionButton()
button.setTitle(suggestion, for: UIControlState())
button.addTarget(self, action: #selector(SuggestionCell.SELdidSelectSuggestion(_:)), for: UIControlEvents.touchUpInside)
button.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(SuggestionCell.SELdidLongPressSuggestion(_:))))
// If this is the first image, add the search icon.
if container.subviews.isEmpty {
let image = UIImage(named: SearchViewControllerUX.SearchImage)
button.setImage(image, for: UIControlState())
button.titleEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0)
}
container.addSubview(button)
}
setNeedsLayout()
}
}
@objc
func SELdidSelectSuggestion(_ sender: UIButton) {
delegate?.suggestionCell(self, didSelectSuggestion: sender.titleLabel!.text!)
}
@objc
func SELdidLongPressSuggestion(_ recognizer: UILongPressGestureRecognizer) {
if recognizer.state == UIGestureRecognizerState.began {
if let button = recognizer.view as! UIButton? {
delegate?.suggestionCell(self, didLongPressSuggestion: button.titleLabel!.text!)
}
}
}
fileprivate override func layoutSubviews() {
super.layoutSubviews()
// The left bounds of the suggestions, aligned with where text would be displayed.
let textLeft: CGFloat = 61
// The maximum width of the container, after which suggestions will wrap to the next line.
let maxWidth = contentView.frame.width
let imageSize = CGFloat(SearchViewControllerUX.FaviconSize)
// The height of the suggestions container (minus margins), used to determine the frame.
// We set it to imageSize.height as a minimum since we don't want the cell to be shorter than the icon
var height: CGFloat = imageSize
var currentLeft = textLeft
var currentTop = SearchViewControllerUX.SuggestionCellVerticalPadding
var currentRow = 0
for view in container.subviews {
let button = view as! UIButton
var buttonSize = button.intrinsicContentSize
// Update our base frame height by the max size of either the image or the button so we never
// make the cell smaller than any of the two
if height == imageSize {
height = max(buttonSize.height, imageSize)
}
var width = currentLeft + buttonSize.width + SearchViewControllerUX.SuggestionMargin
if width > maxWidth {
// Only move to the next row if there's already a suggestion on this row.
// Otherwise, the suggestion is too big to fit and will be resized below.
if currentLeft > textLeft {
currentRow += 1
if currentRow >= SearchViewControllerUX.SuggestionCellMaxRows {
// Don't draw this button if it doesn't fit on the row.
button.frame = CGRect.zero
continue
}
currentLeft = textLeft
currentTop += buttonSize.height + SearchViewControllerUX.SuggestionMargin
height += buttonSize.height + SearchViewControllerUX.SuggestionMargin
width = currentLeft + buttonSize.width + SearchViewControllerUX.SuggestionMargin
}
// If the suggestion is too wide to fit on its own row, shrink it.
if width > maxWidth {
buttonSize.width = maxWidth - currentLeft - SearchViewControllerUX.SuggestionMargin
}
}
button.frame = CGRect(x: currentLeft, y: currentTop, width: buttonSize.width, height: buttonSize.height)
currentLeft += buttonSize.width + SearchViewControllerUX.SuggestionMargin
}
frame.size.height = height + 2 * SearchViewControllerUX.SuggestionCellVerticalPadding
contentView.frame = bounds
container.frame = bounds
let imageX = (textLeft - imageSize) / 2
let imageY = (frame.size.height - imageSize) / 2
imageView!.frame = CGRect(x: imageX, y: imageY, width: imageSize, height: imageSize)
}
}
/**
* Rounded search suggestion button that highlights when selected.
*/
fileprivate class SuggestionButton: InsetButton {
override init(frame: CGRect) {
super.init(frame: frame)
setTitleColor(UIConstants.HighlightBlue, for: UIControlState())
setTitleColor(UIColor.white, for: UIControlState.highlighted)
titleLabel?.font = DynamicFontHelper.defaultHelper.DefaultMediumFont
backgroundColor = SearchViewControllerUX.SuggestionBackgroundColor
layer.borderColor = SearchViewControllerUX.SuggestionBorderColor.cgColor
layer.borderWidth = SearchViewControllerUX.SuggestionBorderWidth
layer.cornerRadius = SearchViewControllerUX.SuggestionCornerRadius
contentEdgeInsets = SearchViewControllerUX.SuggestionInsets
accessibilityHint = NSLocalizedString("Searches for the suggestion", comment: "Accessibility hint describing the action performed when a search suggestion is clicked")
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc
override var isHighlighted: Bool {
didSet {
backgroundColor = isHighlighted ? UIConstants.HighlightBlue : SearchViewControllerUX.SuggestionBackgroundColor
}
}
}