From 2a8e970738937ed3f470a5e0bc851e87b636e150 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 27 Jun 2020 00:22:14 -0400 Subject: [PATCH] Use context menus as primary actions for 'More Actions' buttons on >= iOS 14 --- .../BookmarksTableViewController.swift | 14 -- Tusker/Screens/Utilities/Previewing.swift | 130 +++++++++++++++--- Tusker/TuskerNavigationDelegate.swift | 47 ++++--- Tusker/Views/ContentTextView.swift | 2 +- .../ProfileHeaderTableViewCell.swift | 34 +++-- .../ProfileHeaderTableViewCell.xib | 86 +++++------- .../Status/BaseStatusTableViewCell.swift | 5 + Tusker/Views/VisualEffectImageButton.swift | 70 ++++++++++ 8 files changed, 275 insertions(+), 113 deletions(-) create mode 100644 Tusker/Views/VisualEffectImageButton.swift diff --git a/Tusker/Screens/Bookmarks/BookmarksTableViewController.swift b/Tusker/Screens/Bookmarks/BookmarksTableViewController.swift index c842594d..9c63b3b0 100644 --- a/Tusker/Screens/Bookmarks/BookmarksTableViewController.swift +++ b/Tusker/Screens/Bookmarks/BookmarksTableViewController.swift @@ -149,20 +149,6 @@ class BookmarksTableViewController: EnhancedTableViewController { return config } - override func getSuggestedContextMenuActions(tableView: UITableView, indexPath: IndexPath, point: CGPoint) -> [UIAction] { - guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { return [] } - return [ - UIAction(title: NSLocalizedString("Unbookmark", comment: "unbookmark action title"), image: UIImage(systemName: "bookmark.fill"), identifier: .init("unbookmark"), discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in - let request = Status.unbookmark(status.id) - self.mastodonController.run(request) { (response) in - guard case let .success(newStatus, _) = response else { fatalError() } - self.mastodonController.persistentContainer.addOrUpdate(status: newStatus, incrementReferenceCount: false) - self.statuses.remove(at: indexPath.row) - } - }) - ] - } - } extension BookmarksTableViewController: StatusTableViewCellDelegate { diff --git a/Tusker/Screens/Utilities/Previewing.swift b/Tusker/Screens/Utilities/Previewing.swift index dc5b9073..f91d8f45 100644 --- a/Tusker/Screens/Utilities/Previewing.swift +++ b/Tusker/Screens/Utilities/Previewing.swift @@ -12,7 +12,7 @@ import Pachyderm protocol MenuPreviewProvider { - typealias PreviewProviders = (content: UIContextMenuContentPreviewProvider, actions: () -> [UIAction]) + typealias PreviewProviders = (content: UIContextMenuContentPreviewProvider, actions: () -> [UIMenuElement]) var navigationDelegate: TuskerNavigationDelegate? { get } @@ -28,50 +28,142 @@ extension MenuPreviewProvider { private var mastodonController: MastodonController? { navigationDelegate?.apiController } - func actionsForProfile(accountID: String, sourceView: UIView?) -> [UIAction] { + // Default no-op implementation + func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { + return nil + } + + func actionsForProfile(accountID: String, sourceView: UIView?) -> [UIMenuElement] { guard let mastodonController = mastodonController, let account = mastodonController.persistentContainer.account(for: accountID) else { return [] } - return [ + + var actionsSection: [UIMenuElement] = [ createAction(identifier: "sendmessage", title: "Send Message", systemImageName: "envelope", handler: { (_) in self.navigationDelegate?.compose(mentioning: account.acct) }), - createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { (_) in - self.navigationDelegate?.selected(url: account.url) - }), + ] + + // todo: handle pre-iOS 14 + if accountID != mastodonController.account.id, + #available(iOS 14.0, *) { + actionsSection.append(UIDeferredMenuElement({ (elementHandler) in + guard let mastodonController = self.mastodonController else { + elementHandler([]) + return + } + let request = Client.getRelationships(accounts: [account.id]) + mastodonController.run(request) { (response) in + if case let .success(results, _) = response, + let relationship = results.first { + let following = relationship.following + DispatchQueue.main.async { + elementHandler([ + createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.minus", handler: { (_) in + let request = (following ? Account.unfollow : Account.follow)(accountID) + mastodonController.run(request) { (_) in + } + }) + ]) + } + } + } + })) + } + + let shareSection = [ + openInSafariAction(url: account.url), createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { (_) in self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView) }) ] + + return [ + UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: actionsSection), + UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection) + ] } func actionsForURL(_ url: URL, sourceView: UIView?) -> [UIAction] { return [ - createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { (_) in - self.navigationDelegate?.selected(url: url) - }), + openInSafariAction(url: url), createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { (_) in self.navigationDelegate?.showMoreOptions(forURL: url, sourceView: sourceView) }) ] } - func actionsForHashtag(_ hashtag: Hashtag, sourceView: UIView?) -> [UIAction] { - return actionsForURL(hashtag.url, sourceView: sourceView) + func actionsForHashtag(_ hashtag: Hashtag, sourceView: UIView?) -> [UIMenuElement] { + let account = mastodonController!.accountInfo! + let saved = SavedDataManager.shared.isSaved(hashtag: hashtag, for: account) + + let actionsSection = [ + createAction(identifier: "save", title: saved ? "Unsave Hashtag" : "Save Hashtag", systemImageName: "number", handler: { (_) in + if saved { + SavedDataManager.shared.remove(hashtag: hashtag, for: account) + } else { + SavedDataManager.shared.add(hashtag: hashtag, for: account) + } + }) + ] + + let shareSection = actionsForURL(hashtag.url, sourceView: sourceView) + + return [ + UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: actionsSection), + UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection) + ] } - func actionsForStatus(statusID: String, sourceView: UIView?) -> [UIAction] { + func actionsForStatus(statusID: String, sourceView: UIView?) -> [UIMenuElement] { guard let mastodonController = mastodonController, let status = mastodonController.persistentContainer.status(for: statusID) else { return [] } - return [ + let bookmarked = status.bookmarked ?? false + let muted = status.muted + + var actionsSection = [ createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { (_) in self.navigationDelegate?.reply(to: statusID) }), - createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { (_) in - self.navigationDelegate?.selected(url: status.url!) + createAction(identifier: "bookmark", title: bookmarked ? "Unbookmark" : "Bookmark", systemImageName: bookmarked ? "bookmark.fill" : "bookmark", handler: { (_) in + let request = (bookmarked ? Status.unbookmark : Status.bookmark)(statusID) + self.mastodonController?.run(request) { (response) in + if case let .success(status, _) = response { + self.mastodonController?.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) + } + } }), + createAction(identifier: "mute", title: muted ? "Unmute" : "Mute", systemImageName: muted ? "speaker" : "speaker.slash", handler: { (_) in + let request = (muted ? Status.unmuteConversation : Status.muteConversation)(statusID) + self.mastodonController?.run(request) { (response) in + if case let .success(status, _) = response { + self.mastodonController?.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) + } + } + }) + ] + + if mastodonController.account != nil && mastodonController.account.id == status.account.id { + let pinned = status.pinned ?? false + actionsSection.append(createAction(identifier: "", title: pinned ? "Unpin" : "Pin", systemImageName: pinned ? "pin.slash" : "pin", handler: { (_) in + let request = (pinned ? Status.unpin : Status.pin)(statusID) + self.mastodonController?.run(request, completion: { (response) in + if case let .success(status, _) = response { + self.mastodonController?.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) + } + }) + })) + } + + let shareSection = [ + openInSafariAction(url: status.url!), createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { (_) in self.navigationDelegate?.showMoreOptions(forStatus: statusID, sourceView: sourceView) - }) + }), + ] + + return [ + UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: actionsSection), + UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection), ] } @@ -79,6 +171,12 @@ extension MenuPreviewProvider { return UIAction(title: title, image: UIImage(systemName: systemImageName), identifier: UIAction.Identifier(identifier), discoverabilityTitle: nil, attributes: [], state: .off, handler: handler) } + private func openInSafariAction(url: URL) -> UIAction { + return createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { (_) in + self.navigationDelegate?.selected(url: url) + }) + } + } extension LargeImageViewController: CustomPreviewPresenting { diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index e161ef6e..7d4bd0f2 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -177,32 +177,41 @@ extension TuskerNavigationDelegate where Self: UIViewController { guard let status = apiController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID)") } guard let url = status.url else { fatalError("Missing url for status \(statusID)") } - var customActivites: [UIActivity] = [ - OpenInSafariActivity(), - (status.bookmarked ?? false) ? UnbookmarkStatusActivity() : BookmarkStatusActivity(), - status.muted ? UnmuteConversationActivity() : MuteConversationActivity(), - ] + // on iOS 14+, all these custom actions are in the context menu and don't need to be in the share sheet + if #available(iOS 14.0, *) { + return UIActivityViewController(activityItems: [url, StatusActivityItemSource(status)], applicationActivities: nil) + } else { + var customActivites: [UIActivity] = [ + OpenInSafariActivity(), + (status.bookmarked ?? false) ? UnbookmarkStatusActivity() : BookmarkStatusActivity(), + status.muted ? UnmuteConversationActivity() : MuteConversationActivity(), + ] - if apiController.account != nil, status.account.id == apiController.account.id { - let pinned = status.pinned ?? false - customActivites.insert(pinned ? UnpinStatusActivity() : PinStatusActivity(), at: 1) + if apiController.account != nil, status.account.id == apiController.account.id { + let pinned = status.pinned ?? false + customActivites.insert(pinned ? UnpinStatusActivity() : PinStatusActivity(), at: 1) + } + + let activityController = UIActivityViewController(activityItems: [url, StatusActivityItemSource(status)], applicationActivities: customActivites) + activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(viewController: self, url: url) + return activityController } - - let activityController = UIActivityViewController(activityItems: [url, StatusActivityItemSource(status)], applicationActivities: customActivites) - activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(viewController: self, url: url) - return activityController } private func moreOptions(forAccount accountID: String) -> UIActivityViewController { guard let account = apiController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID)") } - let customActivities: [UIActivity] = [ - OpenInSafariActivity(), - ] - - let activityController = UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: customActivities) - activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(viewController: self, url: account.url) - return activityController + if #available(iOS 14.0, *) { + return UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: nil) + } else { + let customActivities: [UIActivity] = [ + OpenInSafariActivity(), + ] + + let activityController = UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: customActivities) + activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(viewController: self, url: account.url) + return activityController + } } func showMoreOptions(forStatus statusID: String, sourceView: UIView?) { diff --git a/Tusker/Views/ContentTextView.swift b/Tusker/Views/ContentTextView.swift index 01079327..706e6d61 100644 --- a/Tusker/Views/ContentTextView.swift +++ b/Tusker/Views/ContentTextView.swift @@ -267,7 +267,7 @@ extension ContentTextView: UIContextMenuInteractionDelegate { } let actions: UIContextMenuActionProvider = { (_) in let text = (self.text as NSString).substring(with: range) - let actions: [UIAction] + let actions: [UIMenuElement] if let mention = self.getMention(for: link, text: text) { actions = self.actionsForProfile(accountID: mention.id, sourceView: self) } else if let tag = self.getHashtag(for: link, text: text) { diff --git a/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.swift b/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.swift index 526f6710..0ed38707 100644 --- a/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.swift +++ b/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.swift @@ -29,7 +29,7 @@ class ProfileHeaderTableViewCell: UITableViewCell { @IBOutlet weak var fieldsStackView: UIStackView! @IBOutlet weak var fieldNamesStackView: UIStackView! @IBOutlet weak var fieldValuesStackView: UIStackView! - @IBOutlet weak var moreButtonVisualEffectView: UIVisualEffectView! + @IBOutlet weak var moreButton: VisualEffectImageButton! var accountID: String! @@ -45,15 +45,16 @@ class ProfileHeaderTableViewCell: UITableViewCell { avatarImageView.isUserInteractionEnabled = true headerImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(headerPressed))) headerImageView.isUserInteractionEnabled = true - moreButtonVisualEffectView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(morePressed))) - let maskLayer = CAShapeLayer() - maskLayer.frame = moreButtonVisualEffectView.bounds - maskLayer.path = CGPath(ellipseIn: moreButtonVisualEffectView.bounds, transform: nil) - moreButtonVisualEffectView.layer.mask = maskLayer + moreButton.layer.cornerRadius = 16 + moreButton.layer.masksToBounds = true if #available(iOS 13.4, *) { - moreButtonVisualEffectView.addInteraction(UIPointerInteraction(delegate: self)) + moreButton.addInteraction(UIPointerInteraction(delegate: self)) + } + if #available(iOS 14.0, *) { + moreButton.showsMenuAsPrimaryAction = true + moreButton.isContextMenuInteractionEnabled = true } NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil) @@ -84,6 +85,10 @@ class ProfileHeaderTableViewCell: UITableViewCell { } } + if #available(iOS 14.0, *) { + moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForProfile(accountID: accountID, sourceView: moreButton)) + } + noteTextView.navigationDelegate = delegate noteTextView.setTextFromHtml(account.note) noteTextView.setEmojis(account.emojis) @@ -149,7 +154,7 @@ class ProfileHeaderTableViewCell: UITableViewCell { headerRequest?.cancel() } - @objc func morePressed() { + @IBAction func morePressed(_ sender: Any) { delegate?.showMoreOptions(cell: self) } @@ -165,11 +170,16 @@ class ProfileHeaderTableViewCell: UITableViewCell { } +@available(iOS 13.4, *) extension ProfileHeaderTableViewCell: UIPointerInteractionDelegate { - @available(iOS 13.4, *) func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? { - let preview = UITargetedPreview(view: moreButtonVisualEffectView) - let rect = CGRect(x: moreButtonVisualEffectView.frame.minX - 4, y: moreButtonVisualEffectView.frame.minY - 4, width: moreButtonVisualEffectView.frame.width + 8, height: moreButtonVisualEffectView.frame.height + 8) - return UIPointerStyle(effect: .highlight(preview), shape: .roundedRect(rect, radius: 4)) + let preview = UITargetedPreview(view: moreButton) + return UIPointerStyle(effect: .lift(preview), shape: .none) + } +} + +extension ProfileHeaderTableViewCell: MenuPreviewProvider { + var navigationDelegate: TuskerNavigationDelegate? { + delegate } } diff --git a/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.xib b/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.xib index c4c7bde0..8a165a56 100644 --- a/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.xib +++ b/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.xib @@ -1,9 +1,11 @@ - + - + + + @@ -37,7 +39,7 @@ - + @@ -54,7 +56,7 @@ @@ -63,13 +65,13 @@ - + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. - + @@ -90,50 +92,24 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - + + - - + + + + + + + + @@ -146,7 +122,7 @@ - + @@ -156,10 +132,9 @@ - + - @@ -169,7 +144,7 @@ - + @@ -178,5 +153,14 @@ + + + + + + + + + diff --git a/Tusker/Views/Status/BaseStatusTableViewCell.swift b/Tusker/Views/Status/BaseStatusTableViewCell.swift index c589a100..f8d8c61c 100644 --- a/Tusker/Views/Status/BaseStatusTableViewCell.swift +++ b/Tusker/Views/Status/BaseStatusTableViewCell.swift @@ -178,6 +178,11 @@ class BaseStatusTableViewCell: UITableViewCell { collapsible = state.collapsible! setCollapsed(state.collapsed!, animated: false) } + + if #available(iOS 14.0, *) { + moreButton.showsMenuAsPrimaryAction = true + moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForStatus(statusID: statusID, sourceView: moreButton)) + } } func updateStatusState(status: StatusMO) { diff --git a/Tusker/Views/VisualEffectImageButton.swift b/Tusker/Views/VisualEffectImageButton.swift new file mode 100644 index 00000000..9b04a212 --- /dev/null +++ b/Tusker/Views/VisualEffectImageButton.swift @@ -0,0 +1,70 @@ +// +// VisualEffectImageButton.swift +// Tusker +// +// Created by Shadowfacts on 6/26/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit + +class VisualEffectImageButton: UIControl { + + @IBInspectable + var image: UIImage! { + didSet { + imageView?.image = image + } + } + + var menu: UIMenu? + + private(set) var imageView: UIImageView! + + override func awakeFromNib() { + super.awakeFromNib() + + let blur = UIBlurEffect(style: .prominent) + let blurView = UIVisualEffectView(effect: blur) + blurView.translatesAutoresizingMaskIntoConstraints = false + let vibrancy = UIVibrancyEffect(blurEffect: blur, style: .label) + let vibrancyView = UIVisualEffectView(effect: vibrancy) + vibrancyView.translatesAutoresizingMaskIntoConstraints = false + imageView = UIImageView(image: self.image) + imageView.contentMode = .scaleAspectFit + imageView.translatesAutoresizingMaskIntoConstraints = false + vibrancyView.contentView.addSubview(imageView) + blurView.contentView.addSubview(vibrancyView) + addSubview(blurView) + + NSLayoutConstraint.activate([ + blurView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + blurView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + blurView.topAnchor.constraint(equalTo: self.topAnchor), + blurView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + vibrancyView.leadingAnchor.constraint(equalTo: blurView.leadingAnchor), + vibrancyView.trailingAnchor.constraint(equalTo: blurView.trailingAnchor), + vibrancyView.topAnchor.constraint(equalTo: blurView.topAnchor), + vibrancyView.bottomAnchor.constraint(equalTo: blurView.bottomAnchor), + imageView.leadingAnchor.constraint(equalTo: vibrancyView.leadingAnchor, constant: 2), + imageView.trailingAnchor.constraint(equalTo: vibrancyView.trailingAnchor, constant: -2), + imageView.topAnchor.constraint(equalTo: vibrancyView.topAnchor, constant: 2), + imageView.bottomAnchor.constraint(equalTo: vibrancyView.bottomAnchor, constant: -2), + ]) + + addInteraction(UIContextMenuInteraction(delegate: self)) + + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(onTap))) + } + + @objc private func onTap() { + sendActions(for: .touchUpInside) + } + + override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { + guard let menu = menu else { return nil } + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) -> UIMenu? in + return menu + } + } +}