refactor MediaUIView state and logic (#1651)

This commit is contained in:
Thai D. V 2023-11-02 00:50:02 +07:00 committed by GitHub
parent cb1f3dc548
commit 20ecc49e31
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
1 changed files with 194 additions and 119 deletions

View File

@ -1,78 +1,64 @@
import Foundation
import NukeUI
import Nuke import Nuke
import SwiftUI import SwiftUI
import Models import Models
import QuickLook import QuickLook
public struct MediaUIView: View, @unchecked Sendable { public struct MediaUIView: View, @unchecked Sendable {
@Environment(\.dismiss) private var dismiss private let data: [DisplayData]
private let initialItem: DisplayData?
public let selectedAttachment: MediaAttachment @State private var scrolledItem: DisplayData?
public let attachments: [MediaAttachment]
@State private var scrollToId: String?
@State private var altTextDisplayed: String?
@State private var isAltAlertDisplayed: Bool = false
@State private var quickLookURL: URL?
@State private var isLoadingQuickLook = false
@State private var isSavingPhoto: Bool = false
@State private var didSavePhoto: Bool = false
public init(selectedAttachment: MediaAttachment, attachments: [MediaAttachment]) {
self.selectedAttachment = selectedAttachment
self.attachments = attachments
}
public var body: some View { public var body: some View {
NavigationStack { NavigationStack {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
LazyHStack { LazyHStack {
ForEach(attachments) { attachment in ForEach(data) {
if let url = attachment.url { DisplayView(data: $0)
switch attachment.supportedType { .containerRelativeFrame([.horizontal, .vertical])
case .image: .id($0)
MediaUIAttachmentImageView(url: url)
.containerRelativeFrame(.horizontal, count: 1, span: 1, spacing: 0)
.id(attachment.id)
case .video, .gifv, .audio:
MediaUIAttachmentVideoView(viewModel: .init(url: url, forceAutoPlay: true))
.containerRelativeFrame(.horizontal, count: 1, span: 1, spacing: 0)
.containerRelativeFrame(.vertical, count: 1, span: 1, spacing: 0)
.id(attachment.id)
case .none:
EmptyView()
}
}
} }
} }
.scrollTargetLayout() .scrollTargetLayout()
} }
.scrollTargetBehavior(.viewAligned) .scrollTargetBehavior(.viewAligned)
.scrollPosition(id: $scrollToId) .scrollPosition(id: $scrolledItem)
.toolbar { .toolbar {
toolbarView if let item = scrolledItem {
MediaToolBar(data: item)
} }
.alert("status.editor.media.image-description",
isPresented: $isAltAlertDisplayed)
{
Button("alert.button.ok", action: {})
} message: {
Text(altTextDisplayed ?? "")
} }
.quickLookPreview($quickLookURL)
.onAppear { .onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
scrollToId = selectedAttachment.id scrolledItem = initialItem
} }
} }
} }
} }
@ToolbarContentBuilder public init(selectedAttachment: MediaAttachment, attachments: [MediaAttachment]) {
private var toolbarView: some ToolbarContent { self.data = attachments.compactMap { DisplayData(from: $0) }
#if !targetEnvironment(macCatalyst) self.initialItem = DisplayData(from: selectedAttachment)
}
}
private struct MediaToolBar: ToolbarContent {
let data: DisplayData
var body: some ToolbarContent {
#if !targetEnvironment(macCatalyst)
DismissToolbarItem()
#endif
QuickLookToolbarItem(itemUrl: data.url)
AltTextToolbarItem(alt: data.description)
SavePhotoToolbarItem(url: data.url, type: data.type)
ShareToolbarItem(url: data.url, type: data.type)
}
}
private struct DismissToolbarItem: ToolbarContent {
@Environment(\.dismiss) private var dismiss
var body: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
Button { Button {
dismiss() dismiss()
@ -80,86 +66,122 @@ public struct MediaUIView: View, @unchecked Sendable {
Image(systemName: "xmark.circle") Image(systemName: "xmark.circle")
} }
} }
#endif }
}
private struct AltTextToolbarItem: ToolbarContent {
let alt: String?
@State private var isAlertDisplayed = false
var body: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
if let url = attachments.first(where: { $0.id == scrollToId})?.url { if let alt = alt {
Button {
isAlertDisplayed = true
} label: {
Text("status.image.alt-text.abbreviation")
}
.alert("status.editor.media.image-description",
isPresented: $isAlertDisplayed
) {
Button("alert.button.ok", action: {})
} message: {
Text(alt)
}
} else {
EmptyView()
}
}
}
}
private struct SavePhotoToolbarItem: ToolbarContent, @unchecked Sendable {
let url: URL
let type: DisplayType
@State private var state = SavingState.unsaved
var body: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
if type == .image {
Button { Button {
Task { Task {
isLoadingQuickLook = true state = .saving
quickLookURL = await localPathFor(url: url) if await saveImage(url: url) {
isLoadingQuickLook = false withAnimation {
state = .saved
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
state = .unsaved
}
}
}
} }
} label: { } label: {
if isLoadingQuickLook { switch state {
case .unsaved: Image(systemName: "arrow.down.circle")
case .saving : ProgressView()
case .saved : Image(systemName: "checkmark.circle.fill")
}
}
} else {
EmptyView()
}
}
}
private enum SavingState {
case unsaved
case saving
case saved
}
private func imageData(_ url: URL) async -> Data? {
var data = ImagePipeline.shared.cache.cachedData(for: .init(url: url))
if data == nil {
data = try? await URLSession.shared.data(from: url).0
}
return data
}
private func uiimageFor(url: URL) async throws -> UIImage? {
let data = await imageData(url)
if let data {
return UIImage(data: data)
}
return nil
}
private func saveImage(url: URL) async -> Bool {
if let image = try? await uiimageFor(url: url) {
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
return true
}
return false
}
}
private struct QuickLookToolbarItem: ToolbarContent, @unchecked Sendable {
let itemUrl: URL
@State private var localPath: URL?
@State private var isLoading = false
var body: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task {
isLoading = true
localPath = await localPathFor(url: itemUrl)
isLoading = false
}
} label: {
if isLoading {
ProgressView() ProgressView()
} else { } else {
Image(systemName: "info.circle") Image(systemName: "info.circle")
} }
} }
.quickLookPreview($localPath)
} }
} }
ToolbarItem(placement: .topBarTrailing) {
if let alt = attachments.first(where: { $0.id == scrollToId})?.description {
Button {
altTextDisplayed = alt
isAltAlertDisplayed = true
} label: {
Text("status.image.alt-text.abbreviation")
}
}
}
ToolbarItem(placement: .topBarTrailing) {
if let attachment = attachments.first(where: { $0.id == scrollToId}),
let url = attachment.url,
attachment.supportedType == .image {
Button {
Task {
isSavingPhoto = true
if await saveImage(url: url) {
withAnimation {
isSavingPhoto = false
didSavePhoto = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
didSavePhoto = false
}
}
} else {
isSavingPhoto = false
}
}
} label: {
if isSavingPhoto {
ProgressView()
} else if didSavePhoto {
Image(systemName: "checkmark.circle.fill")
} else {
Image(systemName: "arrow.down.circle")
}
}
}
}
ToolbarItem(placement: .topBarTrailing) {
if let attachment = attachments.first(where: { $0.id == scrollToId}),
let url = attachment.url {
switch attachment.supportedType {
case .image:
let transferable = MediaUIImageTransferable(url: url)
ShareLink(item: transferable, preview: .init("status.media.contextmenu.share",
image: transferable))
default:
ShareLink(item: url)
}
}
}
}
private var quickLookDir: URL {
try! FileManager.default.url(for: .cachesDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: false)
.appending(component: "quicklook")
}
private func imageData(_ url: URL) async -> Data? { private func imageData(_ url: URL) async -> Data? {
var data = ImagePipeline.shared.cache.cachedData(for: .init(url: url)) var data = ImagePipeline.shared.cache.cachedData(for: .init(url: url))
@ -178,19 +200,72 @@ public struct MediaUIView: View, @unchecked Sendable {
return path return path
} }
private func uiimageFor(url: URL) async throws -> UIImage? { private var quickLookDir: URL {
let data = await imageData(url) try! FileManager.default.url(for: .cachesDirectory,
if let data { in: .userDomainMask,
return UIImage(data: data) appropriateFor: nil,
} create: false)
return nil .appending(component: "quicklook")
} }
}
private func saveImage(url: URL) async -> Bool {
if let image = try? await uiimageFor(url: url) { private struct ShareToolbarItem: ToolbarContent, @unchecked Sendable {
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) let url: URL
return true let type: DisplayType
}
return false var body: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
if type == .image {
let transferable = MediaUIImageTransferable(url: url)
ShareLink(item: transferable, preview: .init("status.media.contextmenu.share",
image: transferable))
} else {
ShareLink(item: url)
}
}
}
}
private struct DisplayData: Identifiable, Hashable {
let id: String
let url: URL
let description: String?
let type: DisplayType
init?(from attachment: MediaAttachment) {
guard let url = attachment.url else { return nil }
guard let type = attachment.supportedType else { return nil }
self.id = attachment.id
self.url = url
self.description = attachment.description
self.type = DisplayType(from: type)
}
}
private enum DisplayType {
case image
case av
init(from attachmentType: MediaAttachment.SupportedType) {
switch attachmentType {
case .image:
self = .image
case .video, .gifv, .audio:
self = .av
}
}
}
private struct DisplayView: View {
let data: DisplayData
var body: some View {
switch data.type {
case .image:
MediaUIAttachmentImageView(url: data.url)
case .av:
MediaUIAttachmentVideoView(viewModel: .init(url: data.url, forceAutoPlay: true))
}
} }
} }