mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
178
iOS/Article/ArticleSearchBar.swift
Normal file
178
iOS/Article/ArticleSearchBar.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
40
iOS/Article/FindInArticleActivity.swift
Normal file
40
iOS/Article/FindInArticleActivity.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user