Compare commits

..

No commits in common. "1b6f0c07fdeabb323b813d45d7053852cb7b29bc" and "afe47437e4917a1f352d38df06c0809981d0c49c" have entirely different histories.

7 changed files with 145 additions and 126 deletions

View File

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

View File

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

View File

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

View File

@ -30,8 +30,6 @@ class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell {
label.topAnchor.constraint(equalTo: contentView.topAnchor),
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
addInteraction(UIPointerInteraction(delegate: self))
}
required init?(coder: NSCoder) {
@ -42,10 +40,3 @@ class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell {
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,30 +8,19 @@
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 {
private var isManuallyUpdating = false
private var _viewControllers: [UIViewController] = []
var viewControllers: [UIViewController] {
get {
_viewControllers
var viewControllers: [UIViewController] = [] {
didSet {
guard isViewLoaded,
!isManuallyUpdating else {
return
}
set {
_viewControllers = newValue
if isViewLoaded,
!isManuallyUpdating {
updateViews()
scrollToEnd(animated: false)
}
}
}
private var scrollView = UIScrollView()
private var stackView = UIStackView()
@ -79,13 +68,13 @@ class MultiColumnNavigationController: UIViewController {
private func updateViews() {
var i = 0
while i < _viewControllers.count {
while i < viewControllers.count {
let needsCloseButton = i > 0
if i <= stackView.arrangedSubviews.count - 1 {
let existing = stackView.arrangedSubviews[i] as! ColumnView
existing.setContent(_viewControllers[i], needsCloseButton: needsCloseButton)
existing.setContent(viewControllers[i], needsCloseButton: needsCloseButton)
} else {
let new = ColumnView(owner: self, contentViewController: _viewControllers[i], needsCloseButton: needsCloseButton)
let new = ColumnView(owner: self, contentViewController: viewControllers[i], needsCloseButton: needsCloseButton)
stackView.addArrangedSubview(new)
}
i += 1
@ -103,11 +92,9 @@ class MultiColumnNavigationController: UIViewController {
var index: Int? = nil
var current: UIViewController? = sender
while let c = current {
index = _viewControllers.firstIndex(of: c)
index = viewControllers.firstIndex(of: c)
if index != nil {
break
} else if let targetProviding = c as? MultiColumnNavigationCustomTargetProviding {
current = targetProviding.multiColumnNavigationTargetViewController
} else {
current = c.parent
}
@ -125,20 +112,19 @@ class MultiColumnNavigationController: UIViewController {
}
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)
} else {
_viewControllers = Array(_viewControllers[...afterIndex]) + vcs
updateViews()
viewControllers = Array(viewControllers[...afterIndex]) + vcs
scrollToEnd(animated: animated)
}
}
private func scrollToEnd(animated: Bool) {
if _viewControllers.isEmpty {
if viewControllers.isEmpty {
scrollView.setContentOffset(.init(x: -scrollView.adjustedLeadingContentInset, y: -scrollView.adjustedContentInset.top), animated: false)
} else {
scrollColumnToEnd(columnIndex: _viewControllers.count - 1, animated: animated)
scrollColumnToEnd(columnIndex: viewControllers.count - 1, animated: animated)
}
}
@ -156,12 +142,14 @@ class MultiColumnNavigationController: UIViewController {
}
fileprivate func closeColumn(_ vc: UIViewController) {
guard let index = _viewControllers.firstIndex(of: vc),
guard let index = viewControllers.firstIndex(of: vc),
index > 0 else {
// Can't close the last column
return
}
_viewControllers.removeSubrange(index...)
isManuallyUpdating = true
defer { isManuallyUpdating = false }
viewControllers.removeSubrange(index...)
animateChanges {
for column in self.stackView.arrangedSubviews[index...] {
column.layer.opacity = 0
@ -170,6 +158,7 @@ class MultiColumnNavigationController: UIViewController {
} completion: {
self.updateViews()
}
}
private func animateChanges(_ animations: @escaping () -> Void, completion: (() -> Void)? = nil) {
@ -184,22 +173,19 @@ class MultiColumnNavigationController: UIViewController {
extension MultiColumnNavigationController: NavigationControllerProtocol {
var topViewController: UIViewController? {
_viewControllers.last
viewControllers.last
}
func popToRootViewController(animated: Bool) -> [UIViewController]? {
guard !_viewControllers.isEmpty else {
return nil
}
let removed = Array(_viewControllers.dropFirst())
_viewControllers = [_viewControllers.first!]
updateViews()
scrollToEnd(animated: animated)
let removed = Array(viewControllers.dropFirst())
viewControllers = [viewControllers.first!]
return removed
}
func pushViewController(_ vc: UIViewController, animated: Bool) {
_viewControllers.append(vc)
isManuallyUpdating = true
defer { isManuallyUpdating = false }
viewControllers.append(vc)
updateViews()
scrollToEnd(animated: animated)
if animated {

View File

@ -36,8 +36,6 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
var emojiFont: UIFont = .preferredFont(forTextStyle: .body)
var emojiTextColor: UIColor = .label
private let tapRecognizer = UITapGestureRecognizer()
// The link range currently being previewed
private var currentPreviewedLinkRange: NSRange?
// The preview created in the previewForHighlighting method, so that we can use the same one in previewForDismissing.
@ -80,9 +78,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
updateLinkUnderlineStyle()
// the text view's builtin link interaction code is tied to isSelectable, so we need to use our own tap recognizer
tapRecognizer.addTarget(self, action: #selector(textTapped(_:)))
tapRecognizer.delegate = self
addGestureRecognizer(tapRecognizer)
let recognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped(_:)))
addGestureRecognizer(recognizer)
NotificationCenter.default.addObserver(self, selector: #selector(_updateLinkUnderlineStyle), name: UIAccessibility.buttonShapesEnabledStatusDidChangeNotification, object: nil)
underlineTextLinksCancellable =
@ -135,6 +132,12 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
}
@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)
if let (link, range) = getLinkAtPoint(location),
link.scheme != dataDetectorsScheme {
@ -381,25 +384,3 @@ 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,11 +12,7 @@ import SwiftUI
import SafariServices
class ProfileFieldValueView: UIView {
weak var navigationDelegate: TuskerNavigationDelegate? {
didSet {
textView.navigationDelegate = navigationDelegate
}
}
weak var navigationDelegate: TuskerNavigationDelegate?
private static let converter = HTMLConverter(
font: .preferredFont(forTextStyle: .body),
@ -27,8 +23,9 @@ class ProfileFieldValueView: UIView {
private let account: AccountMO
private let field: Account.Field
private var link: (String, URL)?
private let textView = ContentTextView()
private let label = EmojiLabel()
private var iconView: UIView?
private var currentTargetedPreview: UITargetedPreview?
@ -41,28 +38,34 @@ class ProfileFieldValueView: UIView {
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)
textView.linkTextAttributes = [
.foregroundColor: UIColor.link
]
converted.addAttribute(.foregroundColor, value: UIColor.link, range: range)
#else
textView.linkTextAttributes = [
.foregroundColor: UIColor.tintColor
]
converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range)
#endif
textView.backgroundColor = nil
textView.isScrollEnabled = false
textView.isSelectable = false
textView.isEditable = false
textView.textContainerInset = .zero
textView.font = .preferredFont(forTextStyle: .body)
textView.adjustsFontForContentSizeCategory = true
textView.attributedText = converted
textView.setEmojis(account.emojis, identifier: account.id)
textView.isUserInteractionEnabled = true
textView.setContentCompressionResistancePriority(.required, for: .vertical)
textView.translatesAutoresizingMaskIntoConstraints = false
addSubview(textView)
// the .link attribute in a UILabel always makes the color blue >.>
converted.removeAttribute(.link, range: range)
}
}
label.numberOfLines = 0
label.font = .preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
label.attributedText = converted
label.setEmojis(account.emojis, identifier: account.id)
label.setContentCompressionResistancePriority(.required, for: .vertical)
label.translatesAutoresizingMaskIntoConstraints = false
addSubview(label)
let labelTrailingConstraint: NSLayoutConstraint
@ -79,20 +82,20 @@ class ProfileFieldValueView: UIView {
icon.isPointerInteractionEnabled = true
icon.accessibilityLabel = "Verified link"
addSubview(icon)
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
NSLayoutConstraint.activate([
icon.centerYAnchor.constraint(equalTo: textView.centerYAnchor),
icon.centerYAnchor.constraint(equalTo: label.centerYAnchor),
icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
])
} else {
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor)
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: trailingAnchor)
}
NSLayoutConstraint.activate([
textView.leadingAnchor.constraint(equalTo: leadingAnchor),
label.leadingAnchor.constraint(equalTo: leadingAnchor),
labelTrailingConstraint,
textView.topAnchor.constraint(equalTo: topAnchor),
textView.bottomAnchor.constraint(equalTo: bottomAnchor),
label.topAnchor.constraint(equalTo: topAnchor),
label.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
@ -101,7 +104,7 @@ class ProfileFieldValueView: UIView {
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
var size = textView.sizeThatFits(size)
var size = label.sizeThatFits(size)
if let iconView {
size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width
}
@ -109,7 +112,29 @@ class ProfileFieldValueView: UIView {
}
func setTextAlignment(_ alignment: NSTextAlignment) {
textView.textAlignment = alignment
label.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() {
@ -119,7 +144,7 @@ class ProfileFieldValueView: UIView {
let view = ProfileFieldVerificationView(
acct: account.acct,
verifiedAt: field.verifiedAt!,
linkText: textView.text ?? "",
linkText: label.text ?? "",
navigationDelegate: navigationDelegate
)
let host = UIHostingController(rootView: view)
@ -143,3 +168,49 @@ class ProfileFieldValueView: UIView {
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!)
}
}