mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-25 01:31:04 +00:00
New media viewer
This commit is contained in:
parent
017275ec69
commit
fd55020533
16 changed files with 361 additions and 155 deletions
|
@ -85,6 +85,7 @@
|
|||
9FBFE64E292A72BD00C250E9 /* Network in Frameworks */ = {isa = PBXBuildFile; productRef = 9FBFE64D292A72BD00C250E9 /* Network */; };
|
||||
9FD34823293D06E800DB0EE9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9FD34822293D06E800DB0EE9 /* Assets.xcassets */; };
|
||||
9FD542E72962D2FF0045321A /* Lists in Frameworks */ = {isa = PBXBuildFile; productRef = 9FD542E62962D2FF0045321A /* Lists */; };
|
||||
9FE0346C2ADD5C2100529EA8 /* MediaUI in Frameworks */ = {isa = PBXBuildFile; productRef = 9FE0346B2ADD5C2100529EA8 /* MediaUI */; };
|
||||
9FE151A6293C90F900E9683D /* IconSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FE151A5293C90F900E9683D /* IconSelectorView.swift */; };
|
||||
9FE3DB57296FEFCA00628CB0 /* AppAccount in Frameworks */ = {isa = PBXBuildFile; productRef = 9FE3DB56296FEFCA00628CB0 /* AppAccount */; };
|
||||
9FFF677C299B7B2C00FE700A /* Notifications in Frameworks */ = {isa = PBXBuildFile; productRef = 9FFF677B299B7B2C00FE700A /* Notifications */; };
|
||||
|
@ -222,6 +223,8 @@
|
|||
9FCBB3D429859615009B77EE /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
9FD34822293D06E800DB0EE9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
9FD542E52962D2CE0045321A /* Lists */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Lists; path = Packages/Lists; sourceTree = "<group>"; };
|
||||
9FE034692ADD597100529EA8 /* MediaUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = MediaUI; path = Packages/MediaUI; sourceTree = "<group>"; };
|
||||
9FE0346A2ADD59AC00529EA8 /* MediaUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = MediaUI; path = Packages/MediaUI; sourceTree = "<group>"; };
|
||||
9FE151A5293C90F900E9683D /* IconSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSelectorView.swift; sourceTree = "<group>"; };
|
||||
9FE3DB55296FEF5800628CB0 /* AppAccount */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = AppAccount; path = Packages/AppAccount; sourceTree = "<group>"; };
|
||||
B0BAB49E29B3D7A9008F54D7 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
|
||||
|
@ -287,6 +290,7 @@
|
|||
9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */,
|
||||
9F7335EA2966B3F800AFF0BA /* Conversations in Frameworks */,
|
||||
9FE3DB57296FEFCA00628CB0 /* AppAccount in Frameworks */,
|
||||
9FE0346C2ADD5C2100529EA8 /* MediaUI in Frameworks */,
|
||||
9F398AA92935FFDB00A889F2 /* Account in Frameworks */,
|
||||
9FBFE64E292A72BD00C250E9 /* Network in Frameworks */,
|
||||
9FD542E72962D2FF0045321A /* Lists in Frameworks */,
|
||||
|
@ -434,6 +438,7 @@
|
|||
9F55C68E295598F900F94077 /* Explore */,
|
||||
9F5E581729545B5500A53960 /* Env */,
|
||||
9F398AA32935F90100A889F2 /* Models */,
|
||||
9FE0346A2ADD59AC00529EA8 /* MediaUI */,
|
||||
9F29553D292B67B600E0E81B /* Network */,
|
||||
9FD542E52962D2CE0045321A /* Lists */,
|
||||
9F35DB4829506F7F00B3281A /* Notifications */,
|
||||
|
@ -470,6 +475,7 @@
|
|||
9FBFE64C292A72BD00C250E9 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9FE034692ADD597100529EA8 /* MediaUI */,
|
||||
9F2A540D2969A0B0009B2D7C /* StoreKit.framework */,
|
||||
9F2A5404296995FB009B2D7C /* QuickLookUI.framework */,
|
||||
9F7335EE29674F7100AFF0BA /* QuickLook.framework */,
|
||||
|
@ -612,6 +618,7 @@
|
|||
9FE3DB56296FEFCA00628CB0 /* AppAccount */,
|
||||
065FA1FD29866CD600012EA0 /* LRUCache */,
|
||||
DA0B24FA2A6876D50045BDD7 /* SFSafeSymbols */,
|
||||
9FE0346B2ADD5C2100529EA8 /* MediaUI */,
|
||||
);
|
||||
productName = IceCubesApp;
|
||||
productReference = 9FBFE639292A715500C250E9 /* IceCubesApp.app */;
|
||||
|
@ -1481,6 +1488,10 @@
|
|||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Lists;
|
||||
};
|
||||
9FE0346B2ADD5C2100529EA8 /* MediaUI */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = MediaUI;
|
||||
};
|
||||
9FE3DB56296FEFCA00628CB0 /* AppAccount */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = AppAccount;
|
||||
|
|
|
@ -9,6 +9,7 @@ import RevenueCat
|
|||
import Status
|
||||
import SwiftUI
|
||||
import Timeline
|
||||
import MediaUI
|
||||
|
||||
@main
|
||||
struct IceCubesApp: App {
|
||||
|
@ -54,11 +55,14 @@ struct IceCubesApp: App {
|
|||
.environment(watcher)
|
||||
.environment(pushNotificationsService)
|
||||
.environment(\.isSupporter, isSupporter)
|
||||
.fullScreenCover(item: $quickLook.url, content: { url in
|
||||
QuickLookPreview(selectedURL: url, urls: quickLook.urls)
|
||||
.edgesIgnoringSafeArea(.bottom)
|
||||
.background(TransparentBackground())
|
||||
})
|
||||
.sheet(item: $quickLook.selectedMediaAttachment) { selectedMediaAttachment in
|
||||
MediaUIView(selectedAttachment: selectedMediaAttachment,
|
||||
attachments: quickLook.mediaAttachments)
|
||||
.presentationBackground(.ultraThinMaterial)
|
||||
.presentationCornerRadius(16)
|
||||
.environment(userPreferences)
|
||||
.environment(theme)
|
||||
}
|
||||
.onChange(of: pushNotificationsService.handledNotification) { _, newValue in
|
||||
if newValue != nil {
|
||||
pushNotificationsService.handledNotification = nil
|
||||
|
@ -265,8 +269,6 @@ struct IceCubesApp: App {
|
|||
}
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
let themeObserver = ThemeObserverViewController(nibName: nil, bundle: nil)
|
||||
|
||||
func application(_: UIApplication,
|
||||
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
|
||||
{
|
||||
|
@ -300,11 +302,3 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||
return configuration
|
||||
}
|
||||
}
|
||||
|
||||
class ThemeObserverViewController: UIViewController {
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
|
||||
print(traitCollection.userInterfaceStyle.rawValue)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -79,9 +79,8 @@ struct AccountDetailHeaderView: View {
|
|||
guard account.haveHeader else {
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await quickLook.prepareFor(urls: [account.header], selectedURL: account.header)
|
||||
}
|
||||
let attachement = MediaAttachment.imageWith(url: account.header)
|
||||
quickLook.prepareFor(selectedMediaAttachment: attachement, mediaAttachments: [attachement])
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityAddTraits([.isImage, .isButton])
|
||||
|
@ -110,9 +109,8 @@ struct AccountDetailHeaderView: View {
|
|||
guard account.haveAvatar else {
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await quickLook.prepareFor(urls: [account.avatar], selectedURL: account.avatar)
|
||||
}
|
||||
let attachement = MediaAttachment.imageWith(url: account.avatar)
|
||||
quickLook.prepareFor(selectedMediaAttachment: attachement, mediaAttachments: [attachement])
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityAddTraits([.isImage, .isButton])
|
||||
|
|
|
@ -48,19 +48,12 @@ public struct AppAccountsSelectorView: View {
|
|||
labelView
|
||||
}
|
||||
.sheet(isPresented: $isPresented, content: {
|
||||
if #available(iOS 16.4, *) {
|
||||
accountsView.presentationDetents([.height(preferredHeight), .large])
|
||||
.presentationBackground(.thinMaterial)
|
||||
.presentationCornerRadius(16)
|
||||
.onAppear {
|
||||
refreshAccounts()
|
||||
}
|
||||
} else {
|
||||
accountsView.presentationDetents([.height(preferredHeight), .large])
|
||||
.onAppear {
|
||||
refreshAccounts()
|
||||
}
|
||||
}
|
||||
accountsView.presentationDetents([.height(preferredHeight), .large])
|
||||
.presentationBackground(.thinMaterial)
|
||||
.presentationCornerRadius(16)
|
||||
.onAppear {
|
||||
refreshAccounts()
|
||||
}
|
||||
})
|
||||
.onChange(of: currentAccount.account?.id) {
|
||||
refreshAccounts()
|
||||
|
|
|
@ -200,11 +200,7 @@ struct ConversationMessageView: View {
|
|||
.frame(height: 200)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if let url = attachement.url {
|
||||
Task {
|
||||
await quickLook.prepareFor(urls: [url], selectedURL: url)
|
||||
}
|
||||
}
|
||||
quickLook.prepareFor(selectedMediaAttachment: attachement, mediaAttachments: [attachement])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,87 +1,16 @@
|
|||
import Combine
|
||||
@preconcurrency import QuickLook
|
||||
import SwiftUI
|
||||
import Models
|
||||
|
||||
@MainActor
|
||||
@Observable public class QuickLook {
|
||||
public var url: URL? {
|
||||
didSet {
|
||||
if url == nil {
|
||||
cleanup(urls: urls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public private(set) var urls: [URL] = []
|
||||
public private(set) var isPreparing: Bool = false
|
||||
public private(set) var latestError: Error?
|
||||
|
||||
public var selectedMediaAttachment: MediaAttachment?
|
||||
public var mediaAttachments: [MediaAttachment] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
public func prepareFor(urls: [URL], selectedURL: URL) async {
|
||||
var transaction = Transaction(animation: .default)
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
isPreparing = true
|
||||
}
|
||||
do {
|
||||
var order = 0
|
||||
let pathOrderMap = urls.reduce(into: [String: Int]()) { result, url in
|
||||
result[url.lastPathComponent] = order
|
||||
order += 1
|
||||
}
|
||||
let paths: [URL] = try await withThrowingTaskGroup(of: URL.self, body: { group in
|
||||
var paths: [URL] = []
|
||||
for url in urls {
|
||||
group.addTask {
|
||||
try await self.localPathFor(url: url)
|
||||
}
|
||||
}
|
||||
for try await path in group {
|
||||
paths.append(path)
|
||||
}
|
||||
return paths.sorted { url1, url2 in
|
||||
pathOrderMap[url1.lastPathComponent] ?? 0 < pathOrderMap[url2.lastPathComponent] ?? 0
|
||||
}
|
||||
})
|
||||
withTransaction(transaction) {
|
||||
self.urls = paths
|
||||
url = paths.first(where: { $0.lastPathComponent == selectedURL.lastPathComponent })
|
||||
isPreparing = false
|
||||
}
|
||||
} catch {
|
||||
withTransaction(transaction) {
|
||||
isPreparing = false
|
||||
self.urls = []
|
||||
url = nil
|
||||
latestError = error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var quickLookDir: URL {
|
||||
try! FileManager.default.url(for: .cachesDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: false)
|
||||
.appending(component: "quicklook")
|
||||
}
|
||||
|
||||
private func localPathFor(url: URL) async throws -> URL {
|
||||
try? FileManager.default.createDirectory(at: quickLookDir, withIntermediateDirectories: true)
|
||||
let path = quickLookDir.appendingPathComponent(url.lastPathComponent)
|
||||
|
||||
// Warning: Non-sendable type '(any URLSessionTaskDelegate)?' exiting main actor-isolated
|
||||
// context in call to non-isolated instance method 'data(for:delegate:)' cannot cross actor
|
||||
// boundary.
|
||||
// This is on the defaulted-to-nil second parameter of `.data(from:delegate:)`.
|
||||
// There is a Radar tracking this & others like it.
|
||||
let data = try await URLSession.shared.data(from: url).0
|
||||
try data.write(to: path)
|
||||
return path
|
||||
}
|
||||
|
||||
private func cleanup(urls _: [URL]) {
|
||||
try? FileManager.default.removeItem(at: quickLookDir)
|
||||
|
||||
public func prepareFor(selectedMediaAttachment: MediaAttachment?, mediaAttachments: [MediaAttachment]) {
|
||||
self.selectedMediaAttachment = selectedMediaAttachment
|
||||
self.mediaAttachments = mediaAttachments
|
||||
}
|
||||
}
|
||||
|
|
8
Packages/MediaUI/.gitignore
vendored
Normal file
8
Packages/MediaUI/.gitignore
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
32
Packages/MediaUI/Package.swift
Normal file
32
Packages/MediaUI/Package.swift
Normal file
|
@ -0,0 +1,32 @@
|
|||
// swift-tools-version: 5.9
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "MediaUI",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.iOS(.v17),
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "MediaUI",
|
||||
targets: ["MediaUI"]
|
||||
),
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "Models", path: "../Models"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "MediaUI",
|
||||
dependencies: [
|
||||
.product(name: "Models", package: "Models"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableExperimentalFeature("StrictConcurrency"),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
|
@ -0,0 +1,27 @@
|
|||
import SwiftUI
|
||||
import Models
|
||||
import NukeUI
|
||||
|
||||
struct MediaUIAttachmentImageView: View {
|
||||
let url: URL
|
||||
|
||||
@GestureState private var zoom = 1.0
|
||||
|
||||
var body: some View {
|
||||
MediaUIZoomableContainer {
|
||||
LazyImage(url: url) { state in
|
||||
if let image = state.image {
|
||||
image
|
||||
.resizable()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.scaledToFit()
|
||||
.padding(.horizontal, 8)
|
||||
.scaleEffect(zoom)
|
||||
} else if state.isLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,11 +5,11 @@ import Observation
|
|||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@Observable class VideoPlayerViewModel {
|
||||
@Observable public class MediaUIAttachmentVideoViewModel {
|
||||
var player: AVPlayer?
|
||||
private let url: URL
|
||||
|
||||
init(url: URL) {
|
||||
public init(url: URL) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
|
@ -48,15 +48,19 @@ import SwiftUI
|
|||
}
|
||||
}
|
||||
|
||||
struct VideoPlayerView: View {
|
||||
public struct MediaUIAttachmentVideoView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.isCompact) private var isCompact
|
||||
@Environment(UserPreferences.self) private var preferences
|
||||
@Environment(Theme.self) private var theme
|
||||
|
||||
@State var viewModel: VideoPlayerViewModel
|
||||
@State var viewModel: MediaUIAttachmentVideoViewModel
|
||||
|
||||
public init(viewModel: MediaUIAttachmentVideoViewModel) {
|
||||
_viewModel = .init(wrappedValue: viewModel)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
VideoPlayer(player: viewModel.player)
|
||||
.accessibilityAddTraits(.startsMediaSession)
|
121
Packages/MediaUI/Sources/MediaUI/MediaUIView.swift
Normal file
121
Packages/MediaUI/Sources/MediaUI/MediaUIView.swift
Normal file
|
@ -0,0 +1,121 @@
|
|||
import Foundation
|
||||
import NukeUI
|
||||
import Nuke
|
||||
import SwiftUI
|
||||
import Models
|
||||
import QuickLook
|
||||
|
||||
public struct MediaUIView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
public let selectedAttachment: MediaAttachment
|
||||
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?
|
||||
|
||||
public init(selectedAttachment: MediaAttachment, attachments: [MediaAttachment]) {
|
||||
self.selectedAttachment = selectedAttachment
|
||||
self.attachments = attachments
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHStack {
|
||||
ForEach(attachments) { attachment in
|
||||
if let url = attachment.url {
|
||||
switch attachment.supportedType {
|
||||
case .image:
|
||||
MediaUIAttachmentImageView(url: url)
|
||||
.containerRelativeFrame(.horizontal, count: 1, span: 1, spacing: 0)
|
||||
.id(attachment.id)
|
||||
case .video, .gifv, .audio:
|
||||
MediaUIAttachmentVideoView(viewModel: .init(url: url))
|
||||
.containerRelativeFrame(.horizontal, count: 1, span: 1, spacing: 0)
|
||||
.containerRelativeFrame(.vertical, count: 1, span: 1, spacing: 0)
|
||||
.id(attachment.id)
|
||||
case .none:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
.scrollPosition(id: $scrollToId)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if let url = attachments.first(where: { $0.id == scrollToId})?.url {
|
||||
Button {
|
||||
Task {
|
||||
quickLookURL = try? await localPathFor(url: url)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
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 url = attachments.first(where: { $0.id == scrollToId})?.url {
|
||||
ShareLink(item: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("status.editor.media.image-description",
|
||||
isPresented: $isAltAlertDisplayed)
|
||||
{
|
||||
Button("alert.button.ok", action: {})
|
||||
} message: {
|
||||
Text(altTextDisplayed ?? "")
|
||||
}
|
||||
.quickLookPreview($quickLookURL)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
scrollToId = selectedAttachment.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var quickLookDir: URL {
|
||||
try! FileManager.default.url(for: .cachesDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: false)
|
||||
.appending(component: "quicklook")
|
||||
}
|
||||
|
||||
private func localPathFor(url: URL) async throws -> URL {
|
||||
try? FileManager.default.removeItem(at: quickLookDir)
|
||||
try? FileManager.default.createDirectory(at: quickLookDir, withIntermediateDirectories: true)
|
||||
let path = quickLookDir.appendingPathComponent(url.lastPathComponent)
|
||||
var data = ImagePipeline.shared.cache.cachedData(for: .init(url: url))
|
||||
if data == nil {
|
||||
data = try await URLSession.shared.data(from: url).0
|
||||
}
|
||||
try data?.write(to: path)
|
||||
return path
|
||||
}
|
||||
}
|
107
Packages/MediaUI/Sources/MediaUI/MediaUIZoomableContainer.swift
Normal file
107
Packages/MediaUI/Sources/MediaUI/MediaUIZoomableContainer.swift
Normal file
|
@ -0,0 +1,107 @@
|
|||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// ref: https://stackoverflow.com/questions/74238414/is-there-an-easy-way-to-pinch-to-zoom-and-drag-any-view-in-swiftui
|
||||
|
||||
fileprivate let maxAllowedScale = 4.0
|
||||
|
||||
@MainActor
|
||||
struct MediaUIZoomableContainer<Content: View>: View {
|
||||
let content: Content
|
||||
@State private var currentScale: CGFloat = 1.0
|
||||
@State private var tapLocation: CGPoint = .zero
|
||||
|
||||
init(@ViewBuilder content: () -> Content) {
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
func doubleTapAction(location: CGPoint) {
|
||||
tapLocation = location
|
||||
currentScale = currentScale == 1.0 ? maxAllowedScale : 1.0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZoomableScrollView(scale: $currentScale, tapLocation: $tapLocation) {
|
||||
content
|
||||
}
|
||||
.onTapGesture(count: 2, perform: doubleTapAction)
|
||||
}
|
||||
|
||||
fileprivate struct ZoomableScrollView<ScollContent: View>: UIViewRepresentable {
|
||||
private var content: ScollContent
|
||||
@Binding private var currentScale: CGFloat
|
||||
@Binding private var tapLocation: CGPoint
|
||||
|
||||
init(scale: Binding<CGFloat>, tapLocation: Binding<CGPoint>, @ViewBuilder content: () -> ScollContent) {
|
||||
_currentScale = scale
|
||||
_tapLocation = tapLocation
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIScrollView {
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.backgroundColor = .clear
|
||||
scrollView.delegate = context.coordinator
|
||||
scrollView.maximumZoomScale = maxAllowedScale
|
||||
scrollView.minimumZoomScale = 1
|
||||
scrollView.bouncesZoom = true
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.clipsToBounds = false
|
||||
scrollView.backgroundColor = .clear
|
||||
|
||||
let hostedView = context.coordinator.hostingController.view!
|
||||
hostedView.translatesAutoresizingMaskIntoConstraints = true
|
||||
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
hostedView.frame = scrollView.bounds
|
||||
hostedView.backgroundColor = .clear
|
||||
scrollView.addSubview(hostedView)
|
||||
|
||||
return scrollView
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
return Coordinator(hostingController: UIHostingController(rootView: content), scale: $currentScale)
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIScrollView, context: Context) {
|
||||
context.coordinator.hostingController.rootView = content
|
||||
|
||||
if uiView.zoomScale > uiView.minimumZoomScale { // Scale out
|
||||
uiView.setZoomScale(currentScale, animated: true)
|
||||
} else if tapLocation != .zero { // Scale in to a specific point
|
||||
uiView.zoom(to: zoomRect(for: uiView, scale: uiView.maximumZoomScale, center: tapLocation), animated: true)
|
||||
DispatchQueue.main.async { tapLocation = .zero }
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor func zoomRect(for scrollView: UIScrollView, scale: CGFloat, center: CGPoint) -> CGRect {
|
||||
let scrollViewSize = scrollView.bounds.size
|
||||
|
||||
let width = scrollViewSize.width / scale
|
||||
let height = scrollViewSize.height / scale
|
||||
let x = center.x - (width / 2.0)
|
||||
let y = center.y - (height / 2.0)
|
||||
|
||||
return CGRect(x: x, y: y, width: width, height: height)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UIScrollViewDelegate {
|
||||
var hostingController: UIHostingController<ScollContent>
|
||||
@Binding var currentScale: CGFloat
|
||||
|
||||
init(hostingController: UIHostingController<ScollContent>, scale: Binding<CGFloat>) {
|
||||
self.hostingController = hostingController
|
||||
_currentScale = scale
|
||||
}
|
||||
|
||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||
return hostingController.view
|
||||
}
|
||||
|
||||
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
|
||||
currentScale = scale
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -44,6 +44,15 @@ public struct MediaAttachment: Codable, Identifiable, Hashable, Equatable {
|
|||
public let previewUrl: URL?
|
||||
public let description: String?
|
||||
public let meta: MetaContainer?
|
||||
|
||||
public static func imageWith(url: URL) -> MediaAttachment{
|
||||
return .init(id: UUID().uuidString,
|
||||
type: "image",
|
||||
url: url,
|
||||
previewUrl: url,
|
||||
description: nil,
|
||||
meta: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaAttachment: Sendable {}
|
||||
|
|
|
@ -18,6 +18,7 @@ let package = Package(
|
|||
dependencies: [
|
||||
.package(name: "AppAccount", path: "../AppAccount"),
|
||||
.package(name: "Models", path: "../Models"),
|
||||
.package(name: "MediaUI", path: "../MediaUI"),
|
||||
.package(name: "Network", path: "../Network"),
|
||||
.package(name: "Env", path: "../Env"),
|
||||
.package(name: "DesignSystem", path: "../DesignSystem"),
|
||||
|
@ -28,6 +29,7 @@ let package = Package(
|
|||
dependencies: [
|
||||
.product(name: "AppAccount", package: "AppAccount"),
|
||||
.product(name: "Models", package: "Models"),
|
||||
.product(name: "MediaUI", package: "MediaUI"),
|
||||
.product(name: "Network", package: "Network"),
|
||||
.product(name: "Env", package: "Env"),
|
||||
.product(name: "DesignSystem", package: "DesignSystem"),
|
||||
|
|
|
@ -207,10 +207,8 @@ public struct StatusRowView: View {
|
|||
if viewModel.finalStatus.mediaAttachments.isEmpty == false {
|
||||
Button("accessibility.status.media-viewer-action.label") {
|
||||
HapticManager.shared.fireHaptic(of: .notification(.success))
|
||||
Task {
|
||||
let attachments = viewModel.finalStatus.mediaAttachments
|
||||
await quickLook.prepareFor(urls: attachments.compactMap(\.url), selectedURL: attachments[0].url!)
|
||||
}
|
||||
let attachments = viewModel.finalStatus.mediaAttachments
|
||||
quickLook.prepareFor(selectedMediaAttachment: attachments[0], mediaAttachments: attachments)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import Models
|
|||
import Nuke
|
||||
import NukeUI
|
||||
import SwiftUI
|
||||
import MediaUI
|
||||
|
||||
@MainActor
|
||||
public struct StatusRowMediaPreviewView: View {
|
||||
|
@ -87,9 +88,7 @@ public struct StatusRowMediaPreviewView: View {
|
|||
if attachments.count == 1, let attachment = attachments.first {
|
||||
makeFeaturedImagePreview(attachment: attachment)
|
||||
.onTapGesture {
|
||||
Task {
|
||||
await quickLook.prepareFor(urls: attachments.compactMap(\.url), selectedURL: attachment.url!)
|
||||
}
|
||||
quickLook.prepareFor(selectedMediaAttachment: attachment, mediaAttachments: attachments)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(Self.accessibilityLabel(for: attachment))
|
||||
|
@ -117,11 +116,6 @@ public struct StatusRowMediaPreviewView: View {
|
|||
}
|
||||
}
|
||||
.overlay {
|
||||
if quickLook.isPreparing {
|
||||
quickLookLoadingView
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if isHidingMedia {
|
||||
sensitiveMediaOverlay
|
||||
.transition(.opacity)
|
||||
|
@ -182,7 +176,7 @@ public struct StatusRowMediaPreviewView: View {
|
|||
|
||||
case .gifv, .video, .audio:
|
||||
if let url = attachment.url {
|
||||
VideoPlayerView(viewModel: .init(url: url))
|
||||
MediaUIAttachmentVideoView(viewModel: .init(url: url))
|
||||
.frame(width: newSize.width, height: newSize.height)
|
||||
}
|
||||
case .none:
|
||||
|
@ -265,7 +259,7 @@ public struct StatusRowMediaPreviewView: View {
|
|||
}
|
||||
case .gifv, .video, .audio:
|
||||
if let url = attachment.url {
|
||||
VideoPlayerView(viewModel: .init(url: url))
|
||||
MediaUIAttachmentVideoView(viewModel: .init(url: url))
|
||||
.frame(width: isCompact ? imageMaxHeight : proxy.frame(in: .local).width)
|
||||
.frame(height: imageMaxHeight)
|
||||
.accessibilityAddTraits(.startsMediaSession)
|
||||
|
@ -278,9 +272,7 @@ public struct StatusRowMediaPreviewView: View {
|
|||
// #965: do not create overlapping tappable areas, when multiple images are shown
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
Task {
|
||||
await quickLook.prepareFor(urls: attachments.compactMap(\.url), selectedURL: attachment.url!)
|
||||
}
|
||||
quickLook.prepareFor(selectedMediaAttachment: attachment, mediaAttachments: attachments)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(Self.accessibilityLabel(for: attachment))
|
||||
|
@ -288,21 +280,6 @@ public struct StatusRowMediaPreviewView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var quickLookLoadingView: some View {
|
||||
ZStack(alignment: .center) {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
|
||||
private var sensitiveMediaOverlay: some View {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
|
|
Loading…
Reference in a new issue