feat: add context menu for post image
This commit is contained in:
parent
aace886401
commit
aceaa618e0
|
@ -315,6 +315,7 @@
|
|||
DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; };
|
||||
DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; };
|
||||
DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73B48F261F030A002E9E9F /* SafariActivity.swift */; };
|
||||
DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */; };
|
||||
DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; };
|
||||
DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; };
|
||||
DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; };
|
||||
|
@ -381,6 +382,9 @@
|
|||
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; };
|
||||
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; };
|
||||
DBA5E7A3263AD0A3004598BB /* PhotoLibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */; };
|
||||
DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */; };
|
||||
DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */; };
|
||||
DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */; };
|
||||
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; };
|
||||
DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F672615DD60004B8251 /* UserProvider.swift */; };
|
||||
DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */; };
|
||||
|
@ -869,6 +873,7 @@
|
|||
DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = "<group>"; };
|
||||
DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = "<group>"; };
|
||||
DB73B48F261F030A002E9E9F /* SafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariActivity.swift; sourceTree = "<group>"; };
|
||||
DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomScheduler.swift; sourceTree = "<group>"; };
|
||||
DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = "<group>"; };
|
||||
DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = "<group>"; };
|
||||
DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
|
@ -937,6 +942,9 @@
|
|||
DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = "<group>"; };
|
||||
DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = "<group>"; };
|
||||
DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryService.swift; sourceTree = "<group>"; };
|
||||
DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewModel.swift; sourceTree = "<group>"; };
|
||||
DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewController.swift; sourceTree = "<group>"; };
|
||||
DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewCellContextMenuConfiguration.swift; sourceTree = "<group>"; };
|
||||
DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = "<group>"; };
|
||||
DBAE3F672615DD60004B8251 /* UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProvider.swift; sourceTree = "<group>"; };
|
||||
DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = "<group>"; };
|
||||
|
@ -1272,6 +1280,7 @@
|
|||
DB51D170262832380062B7A1 /* BlurHashDecode.swift */,
|
||||
DB51D171262832380062B7A1 /* BlurHashEncode.swift */,
|
||||
DB6180EC26391C6C0018D199 /* TransitioningMath.swift */,
|
||||
DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */,
|
||||
);
|
||||
path = Vender;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1372,6 +1381,7 @@
|
|||
DB68A04F25E9028800CFDF14 /* NavigationController */,
|
||||
DB9D6C2025E502C60051B173 /* ViewModel */,
|
||||
2D7631A525C1532D00929FB9 /* View */,
|
||||
DBA5E7A6263BD298004598BB /* ContextMenu */,
|
||||
);
|
||||
path = Share;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2201,6 +2211,24 @@
|
|||
path = Helper;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBA5E7A6263BD298004598BB /* ContextMenu */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DBA5E7A7263BD29F004598BB /* ImagePreview */,
|
||||
);
|
||||
path = ContextMenu;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBA5E7A7263BD29F004598BB /* ImagePreview */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */,
|
||||
DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */,
|
||||
DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */,
|
||||
);
|
||||
path = ImagePreview;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DBA9B90325F1D4420012E7B6 /* Control */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -2883,6 +2911,7 @@
|
|||
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */,
|
||||
DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */,
|
||||
DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */,
|
||||
DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */,
|
||||
0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */,
|
||||
DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */,
|
||||
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */,
|
||||
|
@ -2943,6 +2972,7 @@
|
|||
5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */,
|
||||
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */,
|
||||
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */,
|
||||
DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */,
|
||||
DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */,
|
||||
DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */,
|
||||
DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */,
|
||||
|
@ -2957,6 +2987,7 @@
|
|||
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
|
||||
5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */,
|
||||
2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */,
|
||||
DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */,
|
||||
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
|
||||
DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */,
|
||||
DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */,
|
||||
|
@ -3036,6 +3067,7 @@
|
|||
DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */,
|
||||
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */,
|
||||
2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */,
|
||||
DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */,
|
||||
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
|
||||
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
|
||||
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>18</integer>
|
||||
<integer>14</integer>
|
||||
</dict>
|
||||
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -32,7 +32,7 @@
|
|||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>17</integer>
|
||||
<integer>15</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
|
|
@ -12,7 +12,7 @@ import os.log
|
|||
import UIKit
|
||||
import AVKit
|
||||
|
||||
protocol StatusCell : DisposeBagCollectable {
|
||||
protocol StatusCell: DisposeBagCollectable {
|
||||
var statusView: StatusView { get }
|
||||
var pollCountdownSubscription: AnyCancellable? { get set }
|
||||
}
|
||||
|
@ -142,7 +142,7 @@ extension StatusSection {
|
|||
status: Status,
|
||||
requestUserID: String,
|
||||
statusItemAttribute: Item.StatusAttribute
|
||||
) {
|
||||
) {
|
||||
// set header
|
||||
StatusSection.configureHeader(cell: cell, status: status)
|
||||
ManagedObjectObserver.observe(object: status)
|
||||
|
|
|
@ -106,4 +106,252 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
|
|||
|
||||
}
|
||||
|
||||
extension StatusTableViewCellDelegate where Self: StatusProvider {}
|
||||
extension StatusTableViewCellDelegate where Self: StatusProvider {
|
||||
|
||||
private typealias ImagePreviewPresentableCell = UITableViewCell & DisposeBagCollectable & MosaicImageViewContainerPresentable
|
||||
|
||||
func handleTableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
guard let imagePreviewPresentableCell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { return nil }
|
||||
guard imagePreviewPresentableCell.isRevealing else { return nil }
|
||||
|
||||
let status = status(for: nil, indexPath: indexPath)
|
||||
|
||||
return contextMenuConfiguration(tableView, status: status, imagePreviewPresentableCell: imagePreviewPresentableCell, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||
}
|
||||
|
||||
private func contextMenuConfiguration(
|
||||
_ tableView: UITableView,
|
||||
status: Future<Status?, Never>,
|
||||
imagePreviewPresentableCell presentable: ImagePreviewPresentableCell,
|
||||
contextMenuConfigurationForRowAt indexPath: IndexPath,
|
||||
point: CGPoint
|
||||
) -> UIContextMenuConfiguration? {
|
||||
let imageViews = presentable.mosaicImageViewContainer.imageViews
|
||||
guard !imageViews.isEmpty else { return nil }
|
||||
|
||||
for (i, imageView) in imageViews.enumerated() {
|
||||
let pointInImageView = imageView.convert(point, from: tableView)
|
||||
guard imageView.point(inside: pointInImageView, with: nil) else {
|
||||
continue
|
||||
}
|
||||
guard let image = imageView.image, image.size != CGSize(width: 1, height: 1) else {
|
||||
// not provide preview until image ready
|
||||
return nil
|
||||
|
||||
}
|
||||
// setup preview
|
||||
let contextMenuImagePreviewViewModel = ContextMenuImagePreviewViewModel(aspectRatio: image.size, thumbnail: image)
|
||||
status
|
||||
.sink { status in
|
||||
guard let status = (status?.reblog ?? status),
|
||||
let media = status.mediaAttachments?.sorted(by:{ $0.index.compare($1.index) == .orderedAscending }),
|
||||
i < media.count, let url = URL(string: media[i].url) else {
|
||||
return
|
||||
}
|
||||
|
||||
contextMenuImagePreviewViewModel.url.value = url
|
||||
}
|
||||
.store(in: &contextMenuImagePreviewViewModel.disposeBag)
|
||||
|
||||
// setup context menu
|
||||
let contextMenuConfiguration = TimelineTableViewCellContextMenuConfiguration(identifier: nil) { () -> UIViewController? in
|
||||
// know issue: preview size looks not as large as system default preview
|
||||
let previewProvider = ContextMenuImagePreviewViewController()
|
||||
previewProvider.viewModel = contextMenuImagePreviewViewModel
|
||||
return previewProvider
|
||||
} actionProvider: { _ -> UIMenu? in
|
||||
let savePhotoAction = UIAction(
|
||||
title: L10n.Common.Controls.Actions.savePhoto, image: UIImage(systemName: "square.and.arrow.down")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off
|
||||
) { [weak self] _ in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: save photo", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
guard let self = self else { return }
|
||||
self.attachment(of: status, index: i)
|
||||
.setFailureType(to: Error.self)
|
||||
.compactMap { attachment -> AnyPublisher<UIImage, Error>? in
|
||||
guard let attachment = attachment, let url = URL(string: attachment.url) else { return nil }
|
||||
return self.context.photoLibraryService.saveImage(url: url)
|
||||
}
|
||||
.sink(receiveCompletion: { _ in
|
||||
// do nothing
|
||||
}, receiveValue: { _ in
|
||||
// do nothing
|
||||
})
|
||||
.store(in: &self.context.disposeBag)
|
||||
}
|
||||
let shareAction = UIAction(
|
||||
title: L10n.Common.Controls.Actions.share, image: UIImage(systemName: "square.and.arrow.up")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off
|
||||
) { [weak self] _ in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: share", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
guard let self = self else { return }
|
||||
self.attachment(of: status, index: i)
|
||||
.sink(receiveValue: { [weak self] attachment in
|
||||
guard let self = self else { return }
|
||||
guard let attachment = attachment, let url = URL(string: attachment.url) else { return }
|
||||
let applicationActivities: [UIActivity] = [
|
||||
SafariActivity(sceneCoordinator: self.coordinator)
|
||||
]
|
||||
let activityViewController = UIActivityViewController(
|
||||
activityItems: [url],
|
||||
applicationActivities: applicationActivities
|
||||
)
|
||||
activityViewController.popoverPresentationController?.sourceView = imageView
|
||||
self.present(activityViewController, animated: true, completion: nil)
|
||||
})
|
||||
.store(in: &self.context.disposeBag)
|
||||
}
|
||||
let children = [savePhotoAction, shareAction]
|
||||
return UIMenu(title: "", image: nil, children: children)
|
||||
}
|
||||
contextMenuConfiguration.indexPath = indexPath
|
||||
contextMenuConfiguration.index = i
|
||||
return contextMenuConfiguration
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func attachment(of status: Future<Status?, Never>, index: Int) -> AnyPublisher<Attachment?, Never> {
|
||||
status
|
||||
.map { status in
|
||||
guard let status = status?.reblog ?? status else { return nil }
|
||||
guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return nil }
|
||||
guard index < media.count else { return nil }
|
||||
return media[index]
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func handleTableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return _handleTableView(tableView, configuration: configuration)
|
||||
}
|
||||
|
||||
func handleTableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return _handleTableView(tableView, configuration: configuration)
|
||||
}
|
||||
|
||||
private func _handleTableView(_ tableView: UITableView, configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return nil }
|
||||
guard let indexPath = configuration.indexPath, let index = configuration.index else { return nil }
|
||||
guard let cell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else {
|
||||
return nil
|
||||
}
|
||||
let imageViews = cell.mosaicImageViewContainer.imageViews
|
||||
guard index < imageViews.count else { return nil }
|
||||
let imageView = imageViews[index]
|
||||
return UITargetedPreview(view: imageView, parameters: UIPreviewParameters())
|
||||
}
|
||||
|
||||
func handleTableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
guard let previewableViewController = self as? MediaPreviewableViewController else { return }
|
||||
guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return }
|
||||
guard let indexPath = configuration.indexPath, let index = configuration.index else { return }
|
||||
guard let cell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { return }
|
||||
let imageViews = cell.mosaicImageViewContainer.imageViews
|
||||
guard index < imageViews.count else { return }
|
||||
let imageView = imageViews[index]
|
||||
|
||||
let status = status(for: nil, indexPath: indexPath)
|
||||
let initialFrame: CGRect? = {
|
||||
guard let previewViewController = animator.previewViewController else { return nil }
|
||||
return UIView.findContextMenuPreviewFrameInWindow(previewController: previewViewController)
|
||||
}()
|
||||
animator.preferredCommitStyle = .pop
|
||||
animator.addCompletion { [weak self] in
|
||||
guard let self = self else { return }
|
||||
status
|
||||
//.delay(for: .milliseconds(500), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] status in
|
||||
guard let self = self else { return }
|
||||
guard let status = (status?.reblog ?? status) else { return }
|
||||
|
||||
let meta = MediaPreviewViewModel.StatusImagePreviewMeta(
|
||||
statusObjectID: status.objectID,
|
||||
initialIndex: index,
|
||||
preloadThumbnailImages: cell.mosaicImageViewContainer.thumbnails()
|
||||
)
|
||||
let pushTransitionItem = MediaPreviewTransitionItem(
|
||||
source: .mosaic(cell.mosaicImageViewContainer),
|
||||
previewableViewController: previewableViewController
|
||||
)
|
||||
pushTransitionItem.aspectRatio = {
|
||||
if let image = imageView.image {
|
||||
return image.size
|
||||
}
|
||||
guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return nil }
|
||||
guard index < media.count else { return nil }
|
||||
let meta = media[index].meta
|
||||
guard let width = meta?.original?.width, let height = meta?.original?.height else { return nil }
|
||||
return CGSize(width: width, height: height)
|
||||
}()
|
||||
pushTransitionItem.sourceImageView = imageView
|
||||
pushTransitionItem.initialFrame = {
|
||||
if let initialFrame = initialFrame {
|
||||
return initialFrame
|
||||
}
|
||||
return imageView.superview!.convert(imageView.frame, to: nil)
|
||||
}()
|
||||
pushTransitionItem.image = {
|
||||
if let image = imageView.image {
|
||||
return image
|
||||
}
|
||||
if index < cell.mosaicImageViewContainer.blurhashOverlayImageViews.count {
|
||||
return cell.mosaicImageViewContainer.blurhashOverlayImageViews[index].image
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
let mediaPreviewViewModel = MediaPreviewViewModel(
|
||||
context: self.context,
|
||||
meta: meta,
|
||||
pushTransitionItem: pushTransitionItem
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
self.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: self, transition: .custom(transitioningDelegate: previewableViewController.mediaPreviewTransitionController))
|
||||
}
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
extension UIView {
|
||||
|
||||
// hack to retrieve preview view frame in window
|
||||
fileprivate static func findContextMenuPreviewFrameInWindow(
|
||||
previewController: UIViewController
|
||||
) -> CGRect? {
|
||||
guard let window = previewController.view.window else { return nil }
|
||||
|
||||
let targetViews = window.subviews
|
||||
.map { $0.findSameSize(view: previewController.view) }
|
||||
.flatMap { $0 }
|
||||
for targetView in targetViews {
|
||||
guard let targetViewSuperview = targetView.superview else { continue }
|
||||
let frame = targetViewSuperview.convert(targetView.frame, to: nil)
|
||||
guard frame.origin.x > 0, frame.origin.y > 0 else { continue }
|
||||
return frame
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func findSameSize(view: UIView) -> [UIView] {
|
||||
var views: [UIView] = []
|
||||
|
||||
if view.bounds.size == bounds.size {
|
||||
views.append(self)
|
||||
}
|
||||
|
||||
for subview in subviews {
|
||||
let targetViews = subview.findSameSize(view: view)
|
||||
views.append(contentsOf: targetViews)
|
||||
}
|
||||
|
||||
return views
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -540,7 +540,7 @@ extension StatusProviderFacade {
|
|||
let meta = MediaPreviewViewModel.StatusImagePreviewMeta(
|
||||
statusObjectID: status.objectID,
|
||||
initialIndex: index,
|
||||
preloadThumbnailImages: mosaicImageView.imageViews.map { $0.image }
|
||||
preloadThumbnailImages: mosaicImageView.thumbnails()
|
||||
)
|
||||
let pushTransitionItem = MediaPreviewTransitionItem(
|
||||
source: .mosaic(mosaicImageView),
|
||||
|
|
|
@ -9,12 +9,12 @@ import UIKit
|
|||
import AVKit
|
||||
|
||||
// Check List Last Updated
|
||||
// - HomeViewController: 2021/4/13
|
||||
// - FavoriteViewController: 2021/4/14
|
||||
// - HashtagTimelineViewController: 2021/4/8
|
||||
// - UserTimelineViewController: 2021/4/13
|
||||
// - ThreadViewController: 2021/4/13
|
||||
// * StatusTableViewControllerAspect: 2021/4/12
|
||||
// - HomeViewController: 2021/4/30
|
||||
// - FavoriteViewController: 2021/4/30
|
||||
// - HashtagTimelineViewController: 2021/4/30
|
||||
// - UserTimelineViewController: 2021/4/30
|
||||
// - ThreadViewController: 2021/4/30
|
||||
// * StatusTableViewControllerAspect: 2021/4/30
|
||||
|
||||
// (Fake) Aspect protocol to group common protocol extension implementations
|
||||
// Needs update related view controller when aspect interface changes
|
||||
|
@ -103,6 +103,38 @@ extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegat
|
|||
}
|
||||
}
|
||||
|
||||
// [B6] aspectTableView(_:contextMenuConfigurationForRowAt:point:)
|
||||
extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider {
|
||||
// [UI] hook to display context menu for images
|
||||
func aspectTableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
return handleTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||
}
|
||||
}
|
||||
|
||||
// [B7] aspectTableView(_:contextMenuConfigurationForRowAt:point:)
|
||||
extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider {
|
||||
// [UI] hook to configure context menu for images
|
||||
func aspectTableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return handleTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
}
|
||||
|
||||
// [B8] aspectTableView(_:previewForDismissingContextMenuWithConfiguration:)
|
||||
extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider {
|
||||
// [UI] hook to configure context menu for images
|
||||
func aspectTableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return handleTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
}
|
||||
|
||||
// [B9] aspectTableView(_:willPerformPreviewActionForMenuWith:animator:)
|
||||
extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider {
|
||||
// [UI] hook to configure context menu preview action
|
||||
func aspectTableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
handleTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSourcePrefetching [C]
|
||||
|
||||
// [C1] aspectTableView(:prefetchRowsAt)
|
||||
|
|
|
@ -224,6 +224,23 @@ extension HashtagTimelineViewController: UITableViewDelegate {
|
|||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
aspectTableView(tableView, didSelectRowAt: indexPath)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
|
||||
|
|
|
@ -378,6 +378,23 @@ extension HomeTimelineViewController: UITableViewDelegate {
|
|||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
aspectTableView(tableView, didSelectRowAt: indexPath)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSourcePrefetching
|
||||
|
|
|
@ -137,9 +137,12 @@ extension MediaPreviewViewController: MediaPreviewingViewController {
|
|||
|
||||
let safeAreaInsets = previewImageView.safeAreaInsets
|
||||
let statusBarFrameHeight = view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0
|
||||
return previewImageView.contentOffset.y <= -(safeAreaInsets.top - statusBarFrameHeight)
|
||||
let dismissable = previewImageView.contentOffset.y <= -(safeAreaInsets.top - statusBarFrameHeight)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissable %s", ((#file as NSString).lastPathComponent), #line, #function, dismissable ? "true" : "false")
|
||||
return dismissable
|
||||
}
|
||||
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissable false", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
return false
|
||||
}
|
||||
|
||||
|
@ -203,12 +206,13 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate {
|
|||
case .savePhoto:
|
||||
switch viewController.viewModel.item {
|
||||
case .status(let meta):
|
||||
context.photoLibraryService.saveImage(url: meta.url).sink { _ in
|
||||
// do nothing
|
||||
} receiveValue: { _ in
|
||||
// do nothing
|
||||
}
|
||||
.store(in: &context.disposeBag)
|
||||
context.photoLibraryService.saveImage(url: meta.url)
|
||||
.sink { _ in
|
||||
// do nothing
|
||||
} receiveValue: { _ in
|
||||
// do nothing
|
||||
}
|
||||
.store(in: &context.disposeBag)
|
||||
case .local(let meta):
|
||||
context.photoLibraryService.save(image: meta.image, withNotificationFeedback: true)
|
||||
}
|
||||
|
|
|
@ -18,6 +18,8 @@ protocol MediaPreviewImageViewControllerDelegate: AnyObject {
|
|||
final class MediaPreviewImageViewController: UIViewController {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var observations = Set<NSKeyValueObservation>()
|
||||
|
||||
var viewModel: MediaPreviewImageViewModel!
|
||||
weak var delegate: MediaPreviewImageViewControllerDelegate?
|
||||
|
||||
|
@ -56,7 +58,7 @@ extension MediaPreviewImageViewController {
|
|||
previewImageView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
previewImageView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
|
||||
tapGestureRecognizer.addTarget(self, action: #selector(MediaPreviewImageViewController.tapGestureRecognizerHandler(_:)))
|
||||
longPressGestureRecognizer.addTarget(self, action: #selector(MediaPreviewImageViewController.longPressGestureRecognizerHandler(_:)))
|
||||
tapGestureRecognizer.require(toFail: previewImageView.doubleTapGestureRecognizer)
|
||||
|
@ -67,39 +69,15 @@ extension MediaPreviewImageViewController {
|
|||
let previewImageViewContextMenuInteraction = UIContextMenuInteraction(delegate: self)
|
||||
previewImageView.addInteraction(previewImageViewContextMenuInteraction)
|
||||
|
||||
switch viewModel.item {
|
||||
case .status(let meta):
|
||||
// progressBarView.isHidden = meta.thumbnail != nil
|
||||
previewImageView.imageView.af.setImage(
|
||||
withURL: meta.url,
|
||||
placeholderImage: meta.thumbnail,
|
||||
filter: nil,
|
||||
progress: { [weak self] progress in
|
||||
guard let self = self else { return }
|
||||
// self.progressBarView.progress.value = CGFloat(progress.fractionCompleted)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: load %s progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, meta.url.debugDescription, progress.fractionCompleted)
|
||||
},
|
||||
imageTransition: .crossDissolve(0.3),
|
||||
runImageTransitionIfCached: false,
|
||||
completion: { [weak self] response in
|
||||
guard let self = self else { return }
|
||||
switch response.result {
|
||||
case .success(let image):
|
||||
//self.progressBarView.isHidden = true
|
||||
self.previewImageView.imageView.image = image
|
||||
self.previewImageView.setup(image: image, container: self.previewImageView, forceUpdate: true)
|
||||
case .failure(let error):
|
||||
// TODO:
|
||||
break
|
||||
}
|
||||
}
|
||||
)
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setImage url: %s", ((#file as NSString).lastPathComponent), #line, #function, meta.url.debugDescription)
|
||||
case .local(let meta):
|
||||
// progressBarView.isHidden = true
|
||||
previewImageView.imageView.image = meta.image
|
||||
self.previewImageView.setup(image: meta.image, container: self.previewImageView, forceUpdate: true)
|
||||
}
|
||||
viewModel.image
|
||||
.receive(on: RunLoop.main) // use RunLoop prevent set image during zooming (TODO: handle transitioning state)
|
||||
.sink { [weak self] image in
|
||||
guard let self = self else { return }
|
||||
guard let image = image else { return }
|
||||
self.previewImageView.imageView.image = image
|
||||
self.previewImageView.setup(image: image, container: self.previewImageView, forceUpdate: true)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -128,14 +106,16 @@ extension MediaPreviewImageViewController: UIContextMenuInteractionDelegate {
|
|||
}
|
||||
|
||||
let saveAction = UIAction(
|
||||
title: L10n.Common.Controls.Actions.savePhoto, image: UIImage(systemName: "square.and.arrow.down")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
|
||||
title: L10n.Common.Controls.Actions.savePhoto, image: UIImage(systemName: "square.and.arrow.down")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off
|
||||
) { [weak self] _ in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: save photo", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
guard let self = self else { return }
|
||||
self.delegate?.mediaPreviewImageViewController(self, contextMenuActionPerform: .savePhoto)
|
||||
}
|
||||
|
||||
let shareAction = UIAction(
|
||||
title: L10n.Common.Controls.Actions.share, image: UIImage(systemName: "square.and.arrow.up")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
|
||||
title: L10n.Common.Controls.Actions.share, image: UIImage(systemName: "square.and.arrow.up")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off
|
||||
) { [weak self] _ in
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: share", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
guard let self = self else { return }
|
||||
self.delegate?.mediaPreviewImageViewController(self, contextMenuActionPerform: .share)
|
||||
|
|
|
@ -5,20 +5,39 @@
|
|||
// Created by MainasuK Cirno on 2021-4-28.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import AlamofireImage
|
||||
|
||||
class MediaPreviewImageViewModel {
|
||||
|
||||
// input
|
||||
let item: ImagePreviewItem
|
||||
|
||||
// output
|
||||
let image: CurrentValueSubject<UIImage?, Never>
|
||||
|
||||
init(meta: RemoteImagePreviewMeta) {
|
||||
self.item = .status(meta)
|
||||
self.image = CurrentValueSubject(meta.thumbnail)
|
||||
|
||||
let url = meta.url
|
||||
ImageDownloader.default.download(URLRequest(url: url), completion: { [weak self] response in
|
||||
guard let self = self else { return }
|
||||
switch response.result {
|
||||
case .failure(let error):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s fail: %s", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription, error.localizedDescription)
|
||||
case .success(let image):
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription)
|
||||
self.image.value = image
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
init(meta: LocalImagePreviewMeta) {
|
||||
self.item = .local(meta)
|
||||
self.image = CurrentValueSubject(meta.image)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -120,6 +120,22 @@ extension FavoriteViewController: UITableViewDelegate {
|
|||
aspectTableView(tableView, didSelectRowAt: indexPath)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSourcePrefetching
|
||||
|
|
|
@ -128,6 +128,22 @@ extension UserTimelineViewController: UITableViewDelegate {
|
|||
aspectTableView(tableView, didSelectRowAt: indexPath)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSourcePrefetching
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
//
|
||||
// ContextMenuImagePreviewViewController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-30.
|
||||
//
|
||||
|
||||
import func AVFoundation.AVMakeRect
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
final class ContextMenuImagePreviewViewController: UIViewController {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
var viewModel: ContextMenuImagePreviewViewModel!
|
||||
|
||||
let imageView: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.layer.masksToBounds = true
|
||||
return imageView
|
||||
}()
|
||||
|
||||
}
|
||||
|
||||
extension ContextMenuImagePreviewViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(imageView)
|
||||
NSLayoutConstraint.activate([
|
||||
imageView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
imageView.image = viewModel.thumbnail
|
||||
|
||||
let frame = AVMakeRect(aspectRatio: viewModel.aspectRatio, insideRect: view.bounds)
|
||||
preferredContentSize = frame.size
|
||||
|
||||
viewModel.url
|
||||
.sink { [weak self] url in
|
||||
guard let self = self else { return }
|
||||
guard let url = url else { return }
|
||||
self.imageView.af.setImage(
|
||||
withURL: url,
|
||||
placeholderImage: self.viewModel.thumbnail,
|
||||
imageTransition: .crossDissolve(0.2),
|
||||
runImageTransitionIfCached: true,
|
||||
completion: nil
|
||||
)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
//
|
||||
// ContextMenuImagePreviewViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-30.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
final class ContextMenuImagePreviewViewModel {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let aspectRatio: CGSize
|
||||
let thumbnail: UIImage?
|
||||
let url = CurrentValueSubject<URL?, Never>(nil)
|
||||
|
||||
init(aspectRatio: CGSize, thumbnail: UIImage?) {
|
||||
self.aspectRatio = aspectRatio
|
||||
self.thumbnail = thumbnail
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
//
|
||||
// TimelineTableViewCellContextMenuConfiguration.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-30.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
// note: use subclass configuration not custom NSCopying identifier due to identifier cause crash issue
|
||||
final class TimelineTableViewCellContextMenuConfiguration: UIContextMenuConfiguration {
|
||||
|
||||
var indexPath: IndexPath?
|
||||
var index: Int?
|
||||
|
||||
}
|
|
@ -9,14 +9,14 @@ import os.log
|
|||
import func AVFoundation.AVMakeRect
|
||||
import UIKit
|
||||
|
||||
protocol MosaicImageViewContainerPresentable: class {
|
||||
protocol MosaicImageViewContainerPresentable: AnyObject {
|
||||
var mosaicImageViewContainer: MosaicImageViewContainer { get }
|
||||
var isRevealing: Bool { get }
|
||||
}
|
||||
|
||||
protocol MosaicImageViewContainerDelegate: class {
|
||||
protocol MosaicImageViewContainerDelegate: AnyObject {
|
||||
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int)
|
||||
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||
|
||||
}
|
||||
|
||||
final class MosaicImageViewContainer: UIView {
|
||||
|
@ -296,7 +296,7 @@ extension MosaicImageViewContainer {
|
|||
|
||||
}
|
||||
|
||||
// FIXME: set imageView source from blurhash and image
|
||||
// FIXME: refactor blurhash image and preview image
|
||||
extension MosaicImageViewContainer {
|
||||
|
||||
func setImageViews(alpha: CGFloat) {
|
||||
|
@ -313,6 +313,22 @@ extension MosaicImageViewContainer {
|
|||
}
|
||||
}
|
||||
|
||||
func thumbnail(at index: Int) -> UIImage? {
|
||||
guard blurhashOverlayImageViews.count == imageViews.count else { return nil }
|
||||
let tuples = Array(zip(blurhashOverlayImageViews, imageViews))
|
||||
guard index < tuples.count else { return nil }
|
||||
let tuple = tuples[index]
|
||||
return tuple.1.image ?? tuple.0.image
|
||||
}
|
||||
|
||||
func thumbnails() -> [UIImage?] {
|
||||
guard blurhashOverlayImageViews.count == imageViews.count else { return [] }
|
||||
let tuples = Array(zip(blurhashOverlayImageViews, imageViews))
|
||||
return tuples.map { blurhashOverlayImageView, imageView -> UIImage? in
|
||||
return imageView.image ?? blurhashOverlayImageView.image
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MosaicImageViewContainer {
|
||||
|
|
|
@ -194,6 +194,8 @@ final class StatusView: UIView {
|
|||
let activeTextLabel = ActiveLabel(style: .default)
|
||||
|
||||
private let headerInfoLabelTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
|
||||
|
||||
var isRevealing = true
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
@ -468,6 +470,8 @@ extension StatusView {
|
|||
}
|
||||
|
||||
func updateRevealContentWarningButton(isRevealing: Bool) {
|
||||
self.isRevealing = isRevealing
|
||||
|
||||
if !isRevealing {
|
||||
let image = traitCollection.userInterfaceStyle == .light ? UIImage(systemName: "eye")! : UIImage(systemName: "eye.fill")
|
||||
revealContentWarningButton.setImage(image, for: .normal)
|
||||
|
|
|
@ -13,7 +13,7 @@ import CoreData
|
|||
import CoreDataStack
|
||||
import ActiveLabel
|
||||
|
||||
protocol StatusTableViewCellDelegate: class {
|
||||
protocol StatusTableViewCellDelegate: AnyObject {
|
||||
var context: AppContext! { get }
|
||||
var managedObjectContext: NSManagedObjectContext { get }
|
||||
|
||||
|
@ -48,7 +48,7 @@ extension StatusTableViewCellDelegate {
|
|||
}
|
||||
|
||||
final class StatusTableViewCell: UITableViewCell, StatusCell {
|
||||
|
||||
|
||||
static let bottomPaddingHeight: CGFloat = 10
|
||||
|
||||
weak var delegate: StatusTableViewCellDelegate?
|
||||
|
@ -62,7 +62,7 @@ final class StatusTableViewCell: UITableViewCell, StatusCell {
|
|||
let threadMetaStackView = UIStackView()
|
||||
let threadMetaView = ThreadMetaView()
|
||||
let separatorLine = UIView.separatorLine
|
||||
|
||||
|
||||
var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint!
|
||||
var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint!
|
||||
|
||||
|
@ -206,6 +206,19 @@ extension StatusTableViewCell {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - MosaicImageViewContainerPresentable
|
||||
extension StatusTableViewCell: MosaicImageViewContainerPresentable {
|
||||
|
||||
var mosaicImageViewContainer: MosaicImageViewContainer {
|
||||
return statusView.statusMosaicImageViewContainer
|
||||
}
|
||||
|
||||
var isRevealing: Bool {
|
||||
return statusView.isRevealing
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
extension StatusTableViewCell: UITableViewDelegate {
|
||||
|
||||
|
|
|
@ -151,6 +151,22 @@ extension ThreadViewController: UITableViewDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||
return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSourcePrefetching
|
||||
|
|
|
@ -360,7 +360,8 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
|
|||
let progress = progressStep(for: translation)
|
||||
|
||||
let initialSize = transitionItem.initialFrame!.size
|
||||
assert(initialSize != .zero)
|
||||
guard initialSize != .zero else { return }
|
||||
// assert(initialSize != .zero)
|
||||
|
||||
guard let snapshot = transitionItem.snapshotTransitioning,
|
||||
let finalSize = transitionItem.targetFrame?.size else {
|
||||
|
|
|
@ -46,7 +46,7 @@ extension MediaPreviewTransitionController {
|
|||
extension MediaPreviewTransitionController: UIGestureRecognizerDelegate {
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer === panGestureRecognizer {
|
||||
if gestureRecognizer === panGestureRecognizer || otherGestureRecognizer === panGestureRecognizer {
|
||||
// FIXME: should enable zoom up pan dismiss
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
//
|
||||
// CustomScheduler.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-30.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
// Ref: https://stackoverflow.com/a/59069315/3797903
|
||||
struct CustomScheduler: Scheduler {
|
||||
var runLoop: RunLoop
|
||||
var modes: [RunLoop.Mode] = [.default]
|
||||
|
||||
func schedule(after date: RunLoop.SchedulerTimeType, interval: RunLoop.SchedulerTimeType.Stride,
|
||||
tolerance: RunLoop.SchedulerTimeType.Stride, options: Never?,
|
||||
_ action: @escaping () -> Void) -> Cancellable {
|
||||
let timer = Timer(fire: date.date, interval: interval.magnitude, repeats: true) { timer in
|
||||
action()
|
||||
}
|
||||
for mode in modes {
|
||||
runLoop.add(timer, forMode: mode)
|
||||
}
|
||||
return AnyCancellable {
|
||||
timer.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
func schedule(after date: RunLoop.SchedulerTimeType, tolerance: RunLoop.SchedulerTimeType.Stride,
|
||||
options: Never?, _ action: @escaping () -> Void) {
|
||||
let timer = Timer(fire: date.date, interval: 0, repeats: false) { timer in
|
||||
timer.invalidate()
|
||||
action()
|
||||
}
|
||||
for mode in modes {
|
||||
runLoop.add(timer, forMode: mode)
|
||||
}
|
||||
}
|
||||
|
||||
func schedule(options: Never?, _ action: @escaping () -> Void) {
|
||||
runLoop.perform(inModes: modes, block: action)
|
||||
}
|
||||
|
||||
var now: RunLoop.SchedulerTimeType { RunLoop.SchedulerTimeType(Date()) }
|
||||
var minimumTolerance: RunLoop.SchedulerTimeType.Stride { RunLoop.SchedulerTimeType.Stride(0.1) }
|
||||
|
||||
typealias SchedulerTimeType = RunLoop.SchedulerTimeType
|
||||
typealias SchedulerOptions = Never
|
||||
}
|
Loading…
Reference in New Issue