Adds "Find in Article" activity to the share sheet

addresses #1750
This commit is contained in:
Brian Sanders
2020-05-11 16:08:01 -04:00
parent 73627a60ca
commit 737f4bfdf5
7 changed files with 723 additions and 15 deletions

View File

@@ -0,0 +1,178 @@
//
// ArticleSearchBar.swift
// NetNewsWire
//
// Created by Brian Sanders on 5/8/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
@objc protocol SearchBarDelegate: NSObjectProtocol {
@objc optional func nextWasPressed(_ searchBar: ArticleSearchBar)
@objc optional func previousWasPressed(_ searchBar: ArticleSearchBar)
@objc optional func doneWasPressed(_ searchBar: ArticleSearchBar)
@objc optional func searchBar(_ searchBar: ArticleSearchBar, textDidChange: String)
}
@IBDesignable final class ArticleSearchBar: UIStackView {
var searchField: UISearchTextField!
var nextButton: UIButton!
var prevButton: UIButton!
var background: UIView!
weak private var resultsLabel: UILabel!
var resultsCount: UInt = 0 {
didSet {
updateUI()
}
}
var selectedResult: UInt = 1 {
didSet {
updateUI()
}
}
weak var delegate: SearchBarDelegate?
override var inputAccessoryView: UIView? {
get {
searchField.inputAccessoryView
}
set {
searchField.inputAccessoryView = newValue
}
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
layer.backgroundColor = UIColor(named: "barBackgroundColor")?.cgColor ?? UIColor.white.cgColor
isOpaque = true
NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: searchField)
}
private func updateUI() {
if resultsCount > 0 {
let format = NSLocalizedString("%d of %d", comment: "Results selection and count")
resultsLabel.text = String.localizedStringWithFormat(format, selectedResult, resultsCount)
} else {
resultsLabel.text = NSLocalizedString("No results", comment: "No results")
}
nextButton.isEnabled = selectedResult < resultsCount
prevButton.isEnabled = resultsCount > 0 && selectedResult > 1
}
@discardableResult override func becomeFirstResponder() -> Bool {
super.becomeFirstResponder()
return searchField.becomeFirstResponder()
}
@discardableResult override func resignFirstResponder() -> Bool {
super.resignFirstResponder()
return searchField.resignFirstResponder()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
private extension ArticleSearchBar {
func commonInit() {
isLayoutMarginsRelativeArrangement = true
alignment = .center
spacing = 8
layoutMargins.left = 8
layoutMargins.right = 8
background = UIView(frame: bounds)
background.backgroundColor = .systemGray5
background.autoresizingMask = [.flexibleWidth, .flexibleHeight]
addSubview(background)
let doneButton = UIButton()
doneButton.setTitle(NSLocalizedString("Done", comment: "Done"), for: .normal)
doneButton.setTitleColor(UIColor.label, for: .normal)
doneButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: 14)
doneButton.isAccessibilityElement = true
doneButton.addTarget(self, action: #selector(donePressed), for: .touchUpInside)
doneButton.isEnabled = true
addArrangedSubview(doneButton)
let resultsLabel = UILabel()
searchField = UISearchTextField()
searchField.autocapitalizationType = .none
searchField.autocorrectionType = .no
searchField.returnKeyType = .search
searchField.delegate = self
resultsLabel.font = .systemFont(ofSize: UIFont.smallSystemFontSize)
resultsLabel.textColor = .secondaryLabel
resultsLabel.text = ""
resultsLabel.textAlignment = .right
resultsLabel.adjustsFontSizeToFitWidth = true
searchField.rightView = resultsLabel
searchField.rightViewMode = .always
self.resultsLabel = resultsLabel
addArrangedSubview(searchField)
prevButton = UIButton(type: .system)
prevButton.setImage(UIImage(systemName: "chevron.up"), for: .normal)
prevButton.accessibilityLabel = "Previous Result"
prevButton.isAccessibilityElement = true
prevButton.addTarget(self, action: #selector(previousPressed), for: .touchUpInside)
addArrangedSubview(prevButton)
nextButton = UIButton(type: .system)
nextButton.setImage(UIImage(systemName: "chevron.down"), for: .normal)
nextButton.accessibilityLabel = "Next Result"
nextButton.isAccessibilityElement = true
nextButton.addTarget(self, action: #selector(nextPressed), for: .touchUpInside)
addArrangedSubview(nextButton)
}
}
private extension ArticleSearchBar {
@objc func textDidChange(_ notification: Notification) {
delegate?.searchBar?(self, textDidChange: searchField.text ?? "")
if searchField.text?.isEmpty ?? true {
searchField.rightViewMode = .never
} else {
searchField.rightViewMode = .always
}
}
@objc func nextPressed() {
delegate?.nextWasPressed?(self)
}
@objc func previousPressed() {
delegate?.previousWasPressed?(self)
}
@objc func donePressed() {
delegate?.doneWasPressed?(self)
}
}
extension ArticleSearchBar: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
delegate?.nextWasPressed?(self)
return false
}
}

View File

@@ -26,6 +26,9 @@ class ArticleViewController: UIViewController {
@IBOutlet private weak var starBarButtonItem: UIBarButtonItem!
@IBOutlet private weak var actionBarButtonItem: UIBarButtonItem!
@IBOutlet private var searchBar: ArticleSearchBar!
private var defaultControls: [UIBarButtonItem]?
private var pageViewController: UIPageViewController!
private var currentWebViewController: WebViewController? {
@@ -127,6 +130,18 @@ class ArticleViewController: UIViewController {
if AppDefaults.articleFullscreenEnabled {
controller.hideBars()
}
// Search bar
makeSearchBarConstraints()
NotificationCenter.default.addObserver(self, selector: #selector(beginFind(_:)), name: .FindInArticle, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(endFind(_:)), name: .EndFindInArticle, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIWindow.keyboardWillHideNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidChangeFrame(_:)), name: UIWindow.keyboardDidChangeFrameNotification, object: nil)
// searchBar.translatesAutoresizingMaskIntoConstraints = false
// searchBar.delegate = self
view.bringSubviewToFront(searchBar)
updateUI()
}
@@ -135,6 +150,10 @@ class ArticleViewController: UIViewController {
coordinator.isArticleViewControllerPending = false
}
override func viewWillDisappear(_ animated: Bool) {
searchBar.inputAccessoryView = nil
}
override func viewSafeAreaInsetsDidChange() {
// This will animate if the show/hide bars animation is happening.
view.layoutIfNeeded()
@@ -276,6 +295,83 @@ class ArticleViewController: UIViewController {
}
// MARK: Find in Article
public extension Notification.Name {
static let FindInArticle = Notification.Name("FindInArticle")
static let EndFindInArticle = Notification.Name("EndFindInArticle")
}
extension ArticleViewController: SearchBarDelegate {
func searchBar(_ searchBar: ArticleSearchBar, textDidChange searchText: String) {
currentWebViewController?.searchText(searchText) {
found in
searchBar.resultsCount = found.count
if let index = found.index {
searchBar.selectedResult = index + 1
}
}
}
func doneWasPressed(_ searchBar: ArticleSearchBar) {
NotificationCenter.default.post(name: .EndFindInArticle, object: nil)
}
func nextWasPressed(_ searchBar: ArticleSearchBar) {
if searchBar.selectedResult < searchBar.resultsCount {
currentWebViewController?.selectNextSearchResult()
searchBar.selectedResult += 1
}
}
func previousWasPressed(_ searchBar: ArticleSearchBar) {
if searchBar.selectedResult > 1 {
currentWebViewController?.selectPreviousSearchResult()
searchBar.selectedResult -= 1
}
}
}
extension ArticleViewController {
private func makeSearchBarConstraints() {
NSLayoutConstraint.activate([
searchBar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
searchBar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
searchBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
])
}
@objc func beginFind(_ notification: Notification) {
searchBar.isHidden = false
navigationController?.setToolbarHidden(true, animated: true)
currentWebViewController?.additionalSafeAreaInsets.bottom = searchBar.frame.height
searchBar.delegate = self
searchBar.inputAccessoryView = searchBar
searchBar.becomeFirstResponder()
}
@objc func endFind(_ notification: Notification) {
searchBar.resignFirstResponder()
searchBar.isHidden = true
navigationController?.setToolbarHidden(false, animated: true)
currentWebViewController?.additionalSafeAreaInsets.bottom = 0
currentWebViewController?.endSearch()
}
@objc func keyboardWillHide(_ _: Notification) {
view.addSubview(searchBar)
makeSearchBarConstraints()
}
@objc func keyboardDidChangeFrame(_ notification: Notification) {
if let frame = notification.userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? CGRect {
currentWebViewController?.additionalSafeAreaInsets.bottom = frame.height
}
}
}
// MARK: WebViewControllerDelegate
extension ArticleViewController: WebViewControllerDelegate {

View File

@@ -0,0 +1,40 @@
//
// FindInArticleActivity.swift
// NetNewsWire-iOS
//
// Created by Brian Sanders on 5/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
class FindInArticleActivity: UIActivity {
override var activityTitle: String? {
NSLocalizedString("Find in Article", comment: "Find in Article")
}
override var activityType: UIActivity.ActivityType? {
UIActivity.ActivityType(rawValue: "com.ranchero.NetNewsWire.find")
}
override var activityImage: UIImage? {
UIImage(systemName: "magnifyingglass", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))
}
override class var activityCategory: UIActivity.Category {
.action
}
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
true
}
override func prepare(withActivityItems activityItems: [Any]) {
}
override func perform() {
NotificationCenter.default.post(Notification(name: .FindInArticle))
activityDidFinish(true)
}
}

View File

@@ -223,7 +223,7 @@ class WebViewController: UIViewController {
return
}
let activityViewController = UIActivityViewController(url: url, title: article?.title, applicationActivities: [OpenInSafariActivity()])
let activityViewController = UIActivityViewController(url: url, title: article?.title, applicationActivities: [FindInArticleActivity(), OpenInSafariActivity()])
activityViewController.popoverPresentationController?.barButtonItem = popOverBarButtonItem
present(activityViewController, animated: true)
}
@@ -678,3 +678,64 @@ private extension WebViewController {
}
}
// MARK: Find in Article
private struct FindInArticleOptions: Codable {
var text: String
var caseSensitive = false
}
internal struct FindInArticleState: Codable {
struct WebViewClientRect: Codable {
let x: Double
let y: Double
let width: Double
let height: Double
}
struct FindInArticleResult: Codable {
let rects: [WebViewClientRect]
let bounds: WebViewClientRect
let index: UInt
let matchGroups: [String]
}
let index: UInt?
let results: [FindInArticleResult]
let count: UInt
}
extension WebViewController {
func searchText(_ searchText: String, completionHandler: @escaping (FindInArticleState) -> Void) {
guard let json = try? JSONEncoder().encode(FindInArticleOptions(text: searchText)) else {
return
}
let encoded = json.base64EncodedString()
webView?.evaluateJavaScript("updateFind(\"\(encoded)\")") {
(result, error) in
guard error == nil,
let b64 = result as? String,
let rawData = Data(base64Encoded: b64),
let findState = try? JSONDecoder().decode(FindInArticleState.self, from: rawData) else {
return
}
completionHandler(findState)
}
}
func endSearch() {
webView?.evaluateJavaScript("endFind()")
}
func selectNextSearchResult() {
webView?.evaluateJavaScript("selectNextResult()")
}
func selectPreviousSearchResult() {
webView?.evaluateJavaScript("selectPreviousResult()")
}
}