refactor MediaUIView state and logic (#1651)
This commit is contained in:
parent
cb1f3dc548
commit
20ecc49e31
|
@ -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) }
|
||||||
|
self.initialItem = DisplayData(from: selectedAttachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MediaToolBar: ToolbarContent {
|
||||||
|
let data: DisplayData
|
||||||
|
|
||||||
|
var body: some ToolbarContent {
|
||||||
#if !targetEnvironment(macCatalyst)
|
#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,85 +66,121 @@ 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 {
|
Button {
|
||||||
Task {
|
isAlertDisplayed = true
|
||||||
isLoadingQuickLook = true
|
|
||||||
quickLookURL = await localPathFor(url: url)
|
|
||||||
isLoadingQuickLook = false
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
if isLoadingQuickLook {
|
|
||||||
ProgressView()
|
|
||||||
} else {
|
|
||||||
Image(systemName: "info.circle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
if let alt = attachments.first(where: { $0.id == scrollToId})?.description {
|
|
||||||
Button {
|
|
||||||
altTextDisplayed = alt
|
|
||||||
isAltAlertDisplayed = true
|
|
||||||
} label: {
|
} label: {
|
||||||
Text("status.image.alt-text.abbreviation")
|
Text("status.image.alt-text.abbreviation")
|
||||||
}
|
}
|
||||||
}
|
.alert("status.editor.media.image-description",
|
||||||
}
|
isPresented: $isAlertDisplayed
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
) {
|
||||||
if let attachment = attachments.first(where: { $0.id == scrollToId}),
|
Button("alert.button.ok", action: {})
|
||||||
let url = attachment.url,
|
} message: {
|
||||||
attachment.supportedType == .image {
|
Text(alt)
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
isSavingPhoto = true
|
|
||||||
if await saveImage(url: url) {
|
|
||||||
withAnimation {
|
|
||||||
isSavingPhoto = false
|
|
||||||
didSavePhoto = true
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
|
||||||
didSavePhoto = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
isSavingPhoto = false
|
EmptyView()
|
||||||
}
|
|
||||||
}
|
|
||||||
} 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 {
|
private struct SavePhotoToolbarItem: ToolbarContent, @unchecked Sendable {
|
||||||
try! FileManager.default.url(for: .cachesDirectory,
|
let url: URL
|
||||||
in: .userDomainMask,
|
let type: DisplayType
|
||||||
appropriateFor: nil,
|
@State private var state = SavingState.unsaved
|
||||||
create: false)
|
|
||||||
.appending(component: "quicklook")
|
var body: some ToolbarContent {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
if type == .image {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
state = .saving
|
||||||
|
if await saveImage(url: url) {
|
||||||
|
withAnimation {
|
||||||
|
state = .saved
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
|
state = .unsaved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
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()
|
||||||
|
} else {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.quickLookPreview($localPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func imageData(_ url: URL) async -> Data? {
|
private func imageData(_ url: URL) async -> Data? {
|
||||||
|
@ -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)
|
||||||
|
.appending(component: "quicklook")
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveImage(url: URL) async -> Bool {
|
private struct ShareToolbarItem: ToolbarContent, @unchecked Sendable {
|
||||||
if let image = try? await uiimageFor(url: url) {
|
let url: URL
|
||||||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
let type: DisplayType
|
||||||
return true
|
|
||||||
}
|
var body: some ToolbarContent {
|
||||||
return false
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue