/* 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) { 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 } } }