forked from zelo72/mastodon-ios
feat: add save photo action for image preview scene
This commit is contained in:
parent
df2a73d96c
commit
aace886401
|
@ -380,6 +380,7 @@
|
||||||
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */; };
|
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */; };
|
||||||
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; };
|
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; };
|
||||||
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; };
|
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; };
|
||||||
|
DBA5E7A3263AD0A3004598BB /* PhotoLibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */; };
|
||||||
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; };
|
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; };
|
||||||
DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F672615DD60004B8251 /* UserProvider.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 */; };
|
DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */; };
|
||||||
|
@ -935,6 +936,7 @@
|
||||||
DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = "<group>"; };
|
DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = "<group>"; };
|
||||||
DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
||||||
DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.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>"; };
|
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>"; };
|
DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = "<group>"; };
|
||||||
|
@ -1289,6 +1291,7 @@
|
||||||
DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */,
|
DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */,
|
||||||
DB4924E126312AB200E9DB22 /* NotificationService.swift */,
|
DB4924E126312AB200E9DB22 /* NotificationService.swift */,
|
||||||
DB6D9F6226357848008423CD /* SettingService.swift */,
|
DB6D9F6226357848008423CD /* SettingService.swift */,
|
||||||
|
DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */,
|
||||||
);
|
);
|
||||||
path = Service;
|
path = Service;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -2864,6 +2867,7 @@
|
||||||
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */,
|
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */,
|
||||||
DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */,
|
DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */,
|
||||||
DB6180F226391CF40018D199 /* MediaPreviewImageViewModel.swift in Sources */,
|
DB6180F226391CF40018D199 /* MediaPreviewImageViewModel.swift in Sources */,
|
||||||
|
DBA5E7A3263AD0A3004598BB /* PhotoLibraryService.swift in Sources */,
|
||||||
5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */,
|
5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */,
|
||||||
DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */,
|
DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */,
|
||||||
2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */,
|
2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */,
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>14</integer>
|
<integer>18</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
@ -32,7 +32,7 @@
|
||||||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>15</integer>
|
<integer>17</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>SuppressBuildableAutocreation</key>
|
<key>SuppressBuildableAutocreation</key>
|
||||||
|
|
|
@ -19,7 +19,7 @@ final class MediaPreviewViewController: UIViewController, NeedsDependency {
|
||||||
|
|
||||||
var disposeBag = Set<AnyCancellable>()
|
var disposeBag = Set<AnyCancellable>()
|
||||||
var viewModel: MediaPreviewViewModel!
|
var viewModel: MediaPreviewViewModel!
|
||||||
|
|
||||||
let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
|
let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
|
||||||
let pagingViewConttroller = MediaPreviewPagingViewController()
|
let pagingViewConttroller = MediaPreviewPagingViewController()
|
||||||
|
|
||||||
|
@ -191,11 +191,38 @@ extension MediaPreviewViewController: PageboyViewControllerDelegate {
|
||||||
extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate {
|
extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate {
|
||||||
|
|
||||||
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer) {
|
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer) {
|
||||||
|
// do nothing
|
||||||
}
|
}
|
||||||
|
|
||||||
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer) {
|
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer) {
|
||||||
// delegate?.mediaPreviewViewController(self, longPressGestureRecognizerTriggered: longPressGestureRecognizer)
|
// do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, contextMenuActionPerform action: MediaPreviewImageViewController.ContextMenuAction) {
|
||||||
|
switch action {
|
||||||
|
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)
|
||||||
|
case .local(let meta):
|
||||||
|
context.photoLibraryService.save(image: meta.image, withNotificationFeedback: true)
|
||||||
|
}
|
||||||
|
case .share:
|
||||||
|
let applicationActivities: [UIActivity] = [
|
||||||
|
SafariActivity(sceneCoordinator: self.coordinator)
|
||||||
|
]
|
||||||
|
let activityViewController = UIActivityViewController(
|
||||||
|
activityItems: viewController.viewModel.item.activityItems,
|
||||||
|
applicationActivities: applicationActivities
|
||||||
|
)
|
||||||
|
activityViewController.popoverPresentationController?.sourceView = viewController.previewImageView.imageView
|
||||||
|
self.present(activityViewController, animated: true, completion: nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,9 +9,10 @@ import os.log
|
||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
protocol MediaPreviewImageViewControllerDelegate: class {
|
protocol MediaPreviewImageViewControllerDelegate: AnyObject {
|
||||||
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer)
|
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer)
|
||||||
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer)
|
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer)
|
||||||
|
func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, contextMenuActionPerform action: MediaPreviewImageViewController.ContextMenuAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
final class MediaPreviewImageViewController: UIViewController {
|
final class MediaPreviewImageViewController: UIViewController {
|
||||||
|
@ -63,6 +64,9 @@ extension MediaPreviewImageViewController {
|
||||||
previewImageView.addGestureRecognizer(tapGestureRecognizer)
|
previewImageView.addGestureRecognizer(tapGestureRecognizer)
|
||||||
previewImageView.addGestureRecognizer(longPressGestureRecognizer)
|
previewImageView.addGestureRecognizer(longPressGestureRecognizer)
|
||||||
|
|
||||||
|
let previewImageViewContextMenuInteraction = UIContextMenuInteraction(delegate: self)
|
||||||
|
previewImageView.addInteraction(previewImageViewContextMenuInteraction)
|
||||||
|
|
||||||
switch viewModel.item {
|
switch viewModel.item {
|
||||||
case .status(let meta):
|
case .status(let meta):
|
||||||
// progressBarView.isHidden = meta.thumbnail != nil
|
// progressBarView.isHidden = meta.thumbnail != nil
|
||||||
|
@ -113,3 +117,50 @@ extension MediaPreviewImageViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - UIContextMenuInteractionDelegate
|
||||||
|
extension MediaPreviewImageViewController: UIContextMenuInteractionDelegate {
|
||||||
|
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
|
||||||
|
let previewProvider: UIContextMenuContentPreviewProvider = { () -> UIViewController? in
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
let actionProvider: UIContextMenuActionProvider = { elements -> UIMenu? in
|
||||||
|
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: [
|
||||||
|
saveAction,
|
||||||
|
shareAction
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return UIContextMenuConfiguration(identifier: nil, previewProvider: previewProvider, actionProvider: actionProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||||
|
// set preview view
|
||||||
|
return UITargetedPreview(view: previewImageView.imageView)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MediaPreviewImageViewController {
|
||||||
|
enum ContextMenuAction {
|
||||||
|
case savePhoto
|
||||||
|
case share
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -27,6 +27,19 @@ extension MediaPreviewImageViewModel {
|
||||||
enum ImagePreviewItem {
|
enum ImagePreviewItem {
|
||||||
case status(RemoteImagePreviewMeta)
|
case status(RemoteImagePreviewMeta)
|
||||||
case local(LocalImagePreviewMeta)
|
case local(LocalImagePreviewMeta)
|
||||||
|
|
||||||
|
var activityItems: [Any] {
|
||||||
|
var items: [Any] = []
|
||||||
|
|
||||||
|
switch self {
|
||||||
|
case .status(let meta):
|
||||||
|
items.append(meta.url)
|
||||||
|
case .local(let meta):
|
||||||
|
items.append(meta.image)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RemoteImagePreviewMeta {
|
struct RemoteImagePreviewMeta {
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
//
|
||||||
|
// PhotoLibraryService.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by MainasuK Cirno on 2021-4-29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
import AlamofireImage
|
||||||
|
|
||||||
|
final class PhotoLibraryService: NSObject {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PhotoLibraryService {
|
||||||
|
|
||||||
|
func saveImage(url: URL) -> AnyPublisher<UIImage, Error> {
|
||||||
|
let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
|
||||||
|
let notificationFeedbackGenerator = UINotificationFeedbackGenerator()
|
||||||
|
|
||||||
|
return Future<UIImage, Error> { promise in
|
||||||
|
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)
|
||||||
|
promise(.failure(error))
|
||||||
|
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.save(image: image)
|
||||||
|
promise(.success(image))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.handleEvents(receiveSubscription: { _ in
|
||||||
|
impactFeedbackGenerator.impactOccurred()
|
||||||
|
}, receiveCompletion: { completion in
|
||||||
|
switch completion {
|
||||||
|
case .failure:
|
||||||
|
notificationFeedbackGenerator.notificationOccurred(.error)
|
||||||
|
case .finished:
|
||||||
|
notificationFeedbackGenerator.notificationOccurred(.success)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(image: UIImage, withNotificationFeedback: Bool = false) {
|
||||||
|
UIImageWriteToSavedPhotosAlbum(
|
||||||
|
image,
|
||||||
|
self,
|
||||||
|
#selector(PhotoLibraryService.image(_:didFinishSavingWithError:contextInfo:)),
|
||||||
|
nil
|
||||||
|
)
|
||||||
|
|
||||||
|
// assert no error
|
||||||
|
if withNotificationFeedback {
|
||||||
|
let notificationFeedbackGenerator = UINotificationFeedbackGenerator()
|
||||||
|
notificationFeedbackGenerator.notificationOccurred(.success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func image(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) {
|
||||||
|
// TODO: notify banner
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -30,7 +30,8 @@ class AppContext: ObservableObject {
|
||||||
let statusPublishService = StatusPublishService()
|
let statusPublishService = StatusPublishService()
|
||||||
let notificationService: NotificationService
|
let notificationService: NotificationService
|
||||||
let settingService: SettingService
|
let settingService: SettingService
|
||||||
|
let photoLibraryService = PhotoLibraryService()
|
||||||
|
|
||||||
let documentStore: DocumentStore
|
let documentStore: DocumentStore
|
||||||
private var documentStoreSubscription: AnyCancellable!
|
private var documentStoreSubscription: AnyCancellable!
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue