Compare commits

...

5 Commits

7 changed files with 126 additions and 145 deletions

View File

@ -59,7 +59,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
resultsController = SearchResultsViewController(mastodonController: mastodonController) resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController! resultsController.exploreNavigationController = self.navigationController!
searchController = MastodonSearchController(searchResultsController: resultsController) searchController = MastodonSearchController(searchResultsController: resultsController, owner: self)
definesPresentationContext = true definesPresentationContext = true
navigationItem.searchController = searchController navigationItem.searchController = searchController

View File

@ -34,7 +34,7 @@ class InlineTrendsViewController: UIViewController {
resultsController = SearchResultsViewController(mastodonController: mastodonController) resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController resultsController.exploreNavigationController = self.navigationController
searchController = MastodonSearchController(searchResultsController: resultsController) searchController = MastodonSearchController(searchResultsController: resultsController, owner: self)
searchController.obscuresBackgroundDuringPresentation = true searchController.obscuresBackgroundDuringPresentation = true
searchController.hidesNavigationBarDuringPresentation = false searchController.hidesNavigationBarDuringPresentation = false
definesPresentationContext = true definesPresentationContext = true

View File

@ -24,7 +24,11 @@ class MastodonSearchController: UISearchController {
super.searchResultsController as! SearchResultsViewController super.searchResultsController as! SearchResultsViewController
} }
init(searchResultsController: SearchResultsViewController) { private weak var owner: UIViewController?
init(searchResultsController: SearchResultsViewController, owner: UIViewController) {
self.owner = owner
super.init(searchResultsController: searchResultsController) super.init(searchResultsController: searchResultsController)
searchResultsController.tokenHandler = { [unowned self] token, op in searchResultsController.tokenHandler = { [unowned self] token, op in
@ -152,6 +156,12 @@ extension MastodonSearchController: UISearchBarDelegate {
} }
} }
extension MastodonSearchController: MultiColumnNavigationCustomTargetProviding {
var multiColumnNavigationTargetViewController: UIViewController? {
owner
}
}
extension UISearchBar { extension UISearchBar {
var searchQueryWithOperators: String { var searchQueryWithOperators: String {
var parts = searchTextField.tokens.compactMap { $0.representedObject as? String } var parts = searchTextField.tokens.compactMap { $0.representedObject as? String }

View File

@ -30,6 +30,8 @@ class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell {
label.topAnchor.constraint(equalTo: contentView.topAnchor), label.topAnchor.constraint(equalTo: contentView.topAnchor),
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
]) ])
addInteraction(UIPointerInteraction(delegate: self))
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -40,3 +42,10 @@ class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell {
label.text = text label.text = text
} }
} }
extension SearchTokenSuggestionCollectionViewCell: UIPointerInteractionDelegate {
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
let preview = UITargetedPreview(view: self)
return UIPointerStyle(effect: .lift(preview))
}
}

View File

@ -8,19 +8,30 @@
import UIKit import UIKit
/// View controllers, such as `UISearchController`, that live outside the normal VC hierarchy
/// can adopt this protocol to indicate to `MultiColumnNavigationController` the context for
/// navigation operations.
protocol MultiColumnNavigationCustomTargetProviding {
var multiColumnNavigationTargetViewController: UIViewController? { get }
}
class MultiColumnNavigationController: UIViewController { class MultiColumnNavigationController: UIViewController {
private var isManuallyUpdating = false private var isManuallyUpdating = false
var viewControllers: [UIViewController] = [] { private var _viewControllers: [UIViewController] = []
didSet { var viewControllers: [UIViewController] {
guard isViewLoaded, get {
!isManuallyUpdating else { _viewControllers
return
} }
set {
_viewControllers = newValue
if isViewLoaded,
!isManuallyUpdating {
updateViews() updateViews()
scrollToEnd(animated: false) scrollToEnd(animated: false)
} }
} }
}
private var scrollView = UIScrollView() private var scrollView = UIScrollView()
private var stackView = UIStackView() private var stackView = UIStackView()
@ -68,13 +79,13 @@ class MultiColumnNavigationController: UIViewController {
private func updateViews() { private func updateViews() {
var i = 0 var i = 0
while i < viewControllers.count { while i < _viewControllers.count {
let needsCloseButton = i > 0 let needsCloseButton = i > 0
if i <= stackView.arrangedSubviews.count - 1 { if i <= stackView.arrangedSubviews.count - 1 {
let existing = stackView.arrangedSubviews[i] as! ColumnView let existing = stackView.arrangedSubviews[i] as! ColumnView
existing.setContent(viewControllers[i], needsCloseButton: needsCloseButton) existing.setContent(_viewControllers[i], needsCloseButton: needsCloseButton)
} else { } else {
let new = ColumnView(owner: self, contentViewController: viewControllers[i], needsCloseButton: needsCloseButton) let new = ColumnView(owner: self, contentViewController: _viewControllers[i], needsCloseButton: needsCloseButton)
stackView.addArrangedSubview(new) stackView.addArrangedSubview(new)
} }
i += 1 i += 1
@ -92,9 +103,11 @@ class MultiColumnNavigationController: UIViewController {
var index: Int? = nil var index: Int? = nil
var current: UIViewController? = sender var current: UIViewController? = sender
while let c = current { while let c = current {
index = viewControllers.firstIndex(of: c) index = _viewControllers.firstIndex(of: c)
if index != nil { if index != nil {
break break
} else if let targetProviding = c as? MultiColumnNavigationCustomTargetProviding {
current = targetProviding.multiColumnNavigationTargetViewController
} else { } else {
current = c.parent current = c.parent
} }
@ -112,19 +125,20 @@ class MultiColumnNavigationController: UIViewController {
} }
func replaceViewControllers(_ vcs: [UIViewController], after afterIndex: Int, animated: Bool) { func replaceViewControllers(_ vcs: [UIViewController], after afterIndex: Int, animated: Bool) {
if afterIndex == viewControllers.count - 1 && vcs.count == 1 { if afterIndex == _viewControllers.count - 1 && vcs.count == 1 {
pushViewController(vcs[0], animated: animated) pushViewController(vcs[0], animated: animated)
} else { } else {
viewControllers = Array(viewControllers[...afterIndex]) + vcs _viewControllers = Array(_viewControllers[...afterIndex]) + vcs
updateViews()
scrollToEnd(animated: animated) scrollToEnd(animated: animated)
} }
} }
private func scrollToEnd(animated: Bool) { private func scrollToEnd(animated: Bool) {
if viewControllers.isEmpty { if _viewControllers.isEmpty {
scrollView.setContentOffset(.init(x: -scrollView.adjustedLeadingContentInset, y: -scrollView.adjustedContentInset.top), animated: false) scrollView.setContentOffset(.init(x: -scrollView.adjustedLeadingContentInset, y: -scrollView.adjustedContentInset.top), animated: false)
} else { } else {
scrollColumnToEnd(columnIndex: viewControllers.count - 1, animated: animated) scrollColumnToEnd(columnIndex: _viewControllers.count - 1, animated: animated)
} }
} }
@ -142,14 +156,12 @@ class MultiColumnNavigationController: UIViewController {
} }
fileprivate func closeColumn(_ vc: UIViewController) { fileprivate func closeColumn(_ vc: UIViewController) {
guard let index = viewControllers.firstIndex(of: vc), guard let index = _viewControllers.firstIndex(of: vc),
index > 0 else { index > 0 else {
// Can't close the last column // Can't close the last column
return return
} }
isManuallyUpdating = true _viewControllers.removeSubrange(index...)
defer { isManuallyUpdating = false }
viewControllers.removeSubrange(index...)
animateChanges { animateChanges {
for column in self.stackView.arrangedSubviews[index...] { for column in self.stackView.arrangedSubviews[index...] {
column.layer.opacity = 0 column.layer.opacity = 0
@ -158,7 +170,6 @@ class MultiColumnNavigationController: UIViewController {
} completion: { } completion: {
self.updateViews() self.updateViews()
} }
} }
private func animateChanges(_ animations: @escaping () -> Void, completion: (() -> Void)? = nil) { private func animateChanges(_ animations: @escaping () -> Void, completion: (() -> Void)? = nil) {
@ -173,19 +184,22 @@ class MultiColumnNavigationController: UIViewController {
extension MultiColumnNavigationController: NavigationControllerProtocol { extension MultiColumnNavigationController: NavigationControllerProtocol {
var topViewController: UIViewController? { var topViewController: UIViewController? {
viewControllers.last _viewControllers.last
} }
func popToRootViewController(animated: Bool) -> [UIViewController]? { func popToRootViewController(animated: Bool) -> [UIViewController]? {
let removed = Array(viewControllers.dropFirst()) guard !_viewControllers.isEmpty else {
viewControllers = [viewControllers.first!] return nil
}
let removed = Array(_viewControllers.dropFirst())
_viewControllers = [_viewControllers.first!]
updateViews()
scrollToEnd(animated: animated)
return removed return removed
} }
func pushViewController(_ vc: UIViewController, animated: Bool) { func pushViewController(_ vc: UIViewController, animated: Bool) {
isManuallyUpdating = true _viewControllers.append(vc)
defer { isManuallyUpdating = false }
viewControllers.append(vc)
updateViews() updateViews()
scrollToEnd(animated: animated) scrollToEnd(animated: animated)
if animated { if animated {

View File

@ -36,6 +36,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
var emojiFont: UIFont = .preferredFont(forTextStyle: .body) var emojiFont: UIFont = .preferredFont(forTextStyle: .body)
var emojiTextColor: UIColor = .label var emojiTextColor: UIColor = .label
private let tapRecognizer = UITapGestureRecognizer()
// The link range currently being previewed // The link range currently being previewed
private var currentPreviewedLinkRange: NSRange? private var currentPreviewedLinkRange: NSRange?
// The preview created in the previewForHighlighting method, so that we can use the same one in previewForDismissing. // The preview created in the previewForHighlighting method, so that we can use the same one in previewForDismissing.
@ -78,8 +80,9 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
updateLinkUnderlineStyle() updateLinkUnderlineStyle()
// the text view's builtin link interaction code is tied to isSelectable, so we need to use our own tap recognizer // the text view's builtin link interaction code is tied to isSelectable, so we need to use our own tap recognizer
let recognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped(_:))) tapRecognizer.addTarget(self, action: #selector(textTapped(_:)))
addGestureRecognizer(recognizer) tapRecognizer.delegate = self
addGestureRecognizer(tapRecognizer)
NotificationCenter.default.addObserver(self, selector: #selector(_updateLinkUnderlineStyle), name: UIAccessibility.buttonShapesEnabledStatusDidChangeNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(_updateLinkUnderlineStyle), name: UIAccessibility.buttonShapesEnabledStatusDidChangeNotification, object: nil)
underlineTextLinksCancellable = underlineTextLinksCancellable =
@ -132,12 +135,6 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
} }
@objc func textTapped(_ recognizer: UITapGestureRecognizer) { @objc func textTapped(_ recognizer: UITapGestureRecognizer) {
// if there currently is a selection, deselct it on single-tap
if selectedRange.length > 0 {
// location doesn't matter since we are non-editable and the cursor isn't visible
selectedRange = NSRange(location: 0, length: 0)
}
let location = recognizer.location(in: self) let location = recognizer.location(in: self)
if let (link, range) = getLinkAtPoint(location), if let (link, range) = getLinkAtPoint(location),
link.scheme != dataDetectorsScheme { link.scheme != dataDetectorsScheme {
@ -384,3 +381,25 @@ extension ContentTextView: UIContextMenuInteractionDelegate {
} }
} }
} }
extension ContentTextView: UIGestureRecognizerDelegate {
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
// NB: This method is both a gesture recognizer delegate method and a UIView method.
// We only want to prevent our own tap gesture recognizer from beginning, but don't
// want to interfere with any other gestures that may begin over this view.
if gestureRecognizer === tapRecognizer {
let location = gestureRecognizer.location(in: self)
if let (link, _) = getLinkAtPoint(location) {
if link.scheme == dataDetectorsScheme {
return false
} else {
return true
}
} else {
return false
}
} else {
return true
}
}
}

View File

@ -12,7 +12,11 @@ import SwiftUI
import SafariServices import SafariServices
class ProfileFieldValueView: UIView { class ProfileFieldValueView: UIView {
weak var navigationDelegate: TuskerNavigationDelegate? weak var navigationDelegate: TuskerNavigationDelegate? {
didSet {
textView.navigationDelegate = navigationDelegate
}
}
private static let converter = HTMLConverter( private static let converter = HTMLConverter(
font: .preferredFont(forTextStyle: .body), font: .preferredFont(forTextStyle: .body),
@ -23,9 +27,8 @@ class ProfileFieldValueView: UIView {
private let account: AccountMO private let account: AccountMO
private let field: Account.Field private let field: Account.Field
private var link: (String, URL)?
private let label = EmojiLabel() private let textView = ContentTextView()
private var iconView: UIView? private var iconView: UIView?
private var currentTargetedPreview: UITargetedPreview? private var currentTargetedPreview: UITargetedPreview?
@ -38,34 +41,28 @@ class ProfileFieldValueView: UIView {
let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value)) let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value))
var range = NSRange(location: 0, length: 0)
if converted.length != 0,
let url = converted.attribute(.link, at: 0, longestEffectiveRange: &range, in: converted.fullRange) as? URL {
link = (converted.attributedSubstring(from: range).string, url)
label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(linkTapped)))
label.addInteraction(UIContextMenuInteraction(delegate: self))
label.isUserInteractionEnabled = true
converted.enumerateAttribute(.link, in: converted.fullRange) { value, range, stop in
guard value != nil else { return }
#if os(visionOS) #if os(visionOS)
converted.addAttribute(.foregroundColor, value: UIColor.link, range: range) textView.linkTextAttributes = [
.foregroundColor: UIColor.link
]
#else #else
converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range) textView.linkTextAttributes = [
.foregroundColor: UIColor.tintColor
]
#endif #endif
// the .link attribute in a UILabel always makes the color blue >.> textView.backgroundColor = nil
converted.removeAttribute(.link, range: range) textView.isScrollEnabled = false
} textView.isSelectable = false
} textView.isEditable = false
textView.textContainerInset = .zero
label.numberOfLines = 0 textView.font = .preferredFont(forTextStyle: .body)
label.font = .preferredFont(forTextStyle: .body) textView.adjustsFontForContentSizeCategory = true
label.adjustsFontForContentSizeCategory = true textView.attributedText = converted
label.attributedText = converted textView.setEmojis(account.emojis, identifier: account.id)
label.setEmojis(account.emojis, identifier: account.id) textView.isUserInteractionEnabled = true
label.setContentCompressionResistancePriority(.required, for: .vertical) textView.setContentCompressionResistancePriority(.required, for: .vertical)
label.translatesAutoresizingMaskIntoConstraints = false textView.translatesAutoresizingMaskIntoConstraints = false
addSubview(label) addSubview(textView)
let labelTrailingConstraint: NSLayoutConstraint let labelTrailingConstraint: NSLayoutConstraint
@ -82,20 +79,20 @@ class ProfileFieldValueView: UIView {
icon.isPointerInteractionEnabled = true icon.isPointerInteractionEnabled = true
icon.accessibilityLabel = "Verified link" icon.accessibilityLabel = "Verified link"
addSubview(icon) addSubview(icon)
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: icon.leadingAnchor) labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
icon.centerYAnchor.constraint(equalTo: label.centerYAnchor), icon.centerYAnchor.constraint(equalTo: textView.centerYAnchor),
icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
]) ])
} else { } else {
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: trailingAnchor) labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor)
} }
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: leadingAnchor), textView.leadingAnchor.constraint(equalTo: leadingAnchor),
labelTrailingConstraint, labelTrailingConstraint,
label.topAnchor.constraint(equalTo: topAnchor), textView.topAnchor.constraint(equalTo: topAnchor),
label.bottomAnchor.constraint(equalTo: bottomAnchor), textView.bottomAnchor.constraint(equalTo: bottomAnchor),
]) ])
} }
@ -104,7 +101,7 @@ class ProfileFieldValueView: UIView {
} }
override func sizeThatFits(_ size: CGSize) -> CGSize { override func sizeThatFits(_ size: CGSize) -> CGSize {
var size = label.sizeThatFits(size) var size = textView.sizeThatFits(size)
if let iconView { if let iconView {
size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width
} }
@ -112,29 +109,7 @@ class ProfileFieldValueView: UIView {
} }
func setTextAlignment(_ alignment: NSTextAlignment) { func setTextAlignment(_ alignment: NSTextAlignment) {
label.textAlignment = alignment textView.textAlignment = alignment
}
func getHashtagOrURL() -> (Hashtag?, URL)? {
guard let (text, url) = link else {
return nil
}
if text.starts(with: "#") {
return (Hashtag(name: String(text.dropFirst()), url: url), url)
} else {
return (nil, url)
}
}
@objc private func linkTapped() {
guard let (hashtag, url) = getHashtagOrURL() else {
return
}
if let hashtag {
navigationDelegate?.selected(tag: hashtag)
} else {
navigationDelegate?.selected(url: url)
}
} }
@objc private func verifiedIconTapped() { @objc private func verifiedIconTapped() {
@ -144,7 +119,7 @@ class ProfileFieldValueView: UIView {
let view = ProfileFieldVerificationView( let view = ProfileFieldVerificationView(
acct: account.acct, acct: account.acct,
verifiedAt: field.verifiedAt!, verifiedAt: field.verifiedAt!,
linkText: label.text ?? "", linkText: textView.text ?? "",
navigationDelegate: navigationDelegate navigationDelegate: navigationDelegate
) )
let host = UIHostingController(rootView: view) let host = UIHostingController(rootView: view)
@ -168,49 +143,3 @@ class ProfileFieldValueView: UIView {
navigationDelegate.present(toPresent, animated: true) navigationDelegate.present(toPresent, animated: true)
} }
} }
extension ProfileFieldValueView: UIContextMenuInteractionDelegate, MenuActionProvider {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
guard let (hashtag, url) = getHashtagOrURL(),
let navigationDelegate else {
return nil
}
if let hashtag {
return UIContextMenuConfiguration {
HashtagTimelineViewController(for: hashtag, mastodonController: navigationDelegate.apiController)
} actionProvider: { _ in
UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self)))
}
} else {
return UIContextMenuConfiguration {
let vc = SFSafariViewController(url: url)
#if !os(visionOS)
vc.preferredControlTintColor = Preferences.shared.accentColor.color
#endif
return vc
} actionProvider: { _ in
UIMenu(children: self.actionsForURL(url, source: .view(self)))
}
}
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, highlightPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? {
var rect = label.textRect(forBounds: label.bounds, limitedToNumberOfLines: 0)
// the rect should be vertically centered, but textRect doesn't seem to take the label's vertical alignment into account
rect.origin.x = 0
rect.origin.y = (bounds.height - rect.height) / 2
let parameters = UIPreviewParameters(textLineRects: [rect as NSValue])
let preview = UITargetedPreview(view: label, parameters: parameters)
currentTargetedPreview = preview
return preview
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, dismissalPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? {
return currentTargetedPreview
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: navigationDelegate!)
}
}