Compare commits
No commits in common. "1b6f0c07fdeabb323b813d45d7053852cb7b29bc" and "afe47437e4917a1f352d38df06c0809981d0c49c" have entirely different histories.
1b6f0c07fd
...
afe47437e4
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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!)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue