2024-02-18 18:39:07 +01:00
// M a d e b y L u m a a
import SwiftUI
2024-02-21 08:27:45 +01:00
import UIKit
import AVKit
2024-02-18 18:39:07 +01:00
struct AttachmentView : View {
@ Environment ( \ . dismiss ) private var dismiss
@ Environment ( AppDelegate . self ) private var appDelegate
var attachments : [ MediaAttachment ]
@ State var selectedId : String = " "
private var selectedAttachment : MediaAttachment ? {
guard ! selectedId . isEmpty else { return nil }
return attachments . filter ( { $0 . id = = selectedId } ) [ 0 ]
}
2024-02-21 08:27:45 +01:00
@ State private var player : AVPlayer ?
2024-02-18 18:39:07 +01:00
@ State private var readAlt : Bool = false
2024-02-20 20:57:50 +01:00
@ State private var hasSwitch : Bool = false
2024-02-18 18:39:07 +01:00
@ State private var currentZoom = 0.0
@ State private var totalZoom = 1.0
@ State private var currentPos : CGSize = . zero
@ State private var totalPos : CGSize = . zero
var body : some View {
GeometryReader { geo in
let size = geo . size
ZStack ( alignment : . center ) {
Color . appBackground
. ignoresSafeArea ( )
if ! attachments . isEmpty {
TabView ( selection : $ selectedId ) {
ForEach ( attachments ) { atchmnt in
2024-02-21 08:27:45 +01:00
ZStack {
if atchmnt . supportedType = = . image {
AsyncImage ( url : atchmnt . url , content : { image in
image
. resizable ( )
. scaledToFit ( )
. frame ( width : size . width )
. ignoresSafeArea ( )
} , placeholder : {
ZStack {
Rectangle ( )
. fill ( Color . gray )
. frame ( width : size . width - 10 , height : size . width - 10 )
ProgressView ( )
. progressViewStyle ( . circular )
}
} )
. tag ( atchmnt . id )
2024-02-18 18:39:07 +01:00
. ignoresSafeArea ( )
2024-02-21 08:27:45 +01:00
} else if atchmnt . supportedType = = . video {
ZStack {
if player != nil {
VideoPlayer ( player : player )
. scaledToFit ( )
. frame ( width : size . width )
. ignoresSafeArea ( )
} else {
Color . gray
ProgressView ( )
. progressViewStyle ( . circular )
}
}
. onAppear {
if let url = atchmnt . url {
player = AVPlayer ( url : url )
player ? . preventsDisplaySleepDuringVideoPlayback = false
player ? . audiovisualBackgroundPlaybackPolicy = . pauses
player ? . isMuted = true
player ? . play ( )
}
}
. onDisappear ( ) {
guard player != nil else { return }
player ? . pause ( )
}
} else if atchmnt . supportedType = = . gifv {
ZStack ( alignment : . center ) {
if player != nil {
VideoPlayer ( player : player )
. scaledToFit ( )
. frame ( width : size . width )
. ignoresSafeArea ( )
} else {
Color . gray
ProgressView ( )
. progressViewStyle ( . circular )
}
}
. onAppear {
if let url = atchmnt . url {
player = AVPlayer ( url : url )
player ? . preventsDisplaySleepDuringVideoPlayback = false
player ? . audiovisualBackgroundPlaybackPolicy = . pauses
player ? . isMuted = true
player ? . play ( )
guard let player else { return }
NotificationCenter . default . addObserver ( forName : . AVPlayerItemDidPlayToEndTime , object : player . currentItem , queue : . main ) { _ in
Task { @ MainActor in
player . seek ( to : CMTime . zero )
player . play ( )
}
}
}
}
. onDisappear ( ) {
guard player != nil else { return }
player ? . pause ( )
}
2024-02-18 18:39:07 +01:00
}
2024-02-21 08:27:45 +01:00
}
2024-02-18 18:39:07 +01:00
. offset ( x : currentPos . width + totalPos . width , y : currentPos . height + totalPos . height )
. scaleEffect ( currentZoom + totalZoom )
}
}
. ignoresSafeArea ( )
. tabViewStyle ( PageTabViewStyle ( ) )
. indexViewStyle ( PageIndexViewStyle ( backgroundDisplayMode : . always ) )
. onAppear {
UIScrollView . appearance ( ) . isScrollEnabled = false
if selectedId . isEmpty {
selectedId = attachments [ 0 ] . id
}
}
. onChange ( of : selectedId ) { _ , new in
2024-02-24 13:24:14 +01:00
currentZoom = 0.0
totalZoom = 1.0
2024-02-18 18:39:07 +01:00
currentPos = . zero
totalPos = . zero
}
} else {
ContentUnavailableView ( " attachment.no-attachments " , systemImage : " rectangle.slash " )
. foregroundStyle ( . white )
}
}
. alert ( String ( " ALT " ) , isPresented : $ readAlt , actions : {
Button ( role : . cancel ) {
readAlt . toggle ( )
} label : {
Text ( " attachment.alt.action " )
}
} , message : {
Text ( selectedAttachment ? . description ? ? " No ALT text here. " )
} )
. gesture (
MagnifyGesture ( )
. onChanged { value in
if ( totalZoom + value . magnification - 1 > 1 ) {
currentZoom = value . magnification - 1
} else {
withAnimation ( . spring . speed ( 2.0 ) ) {
currentPos = . zero
totalPos = . zero
}
}
}
. onEnded { value in
totalZoom += currentZoom
totalZoom = max ( 1 , totalZoom )
currentZoom = 0
2024-02-29 13:33:49 +01:00
withAnimation ( . spring . speed ( 2.0 ) ) {
totalZoom = min ( 5 , totalZoom )
}
2024-02-18 18:39:07 +01:00
}
)
. highPriorityGesture (
DragGesture ( )
. onChanged { gesture in
2024-02-20 20:57:50 +01:00
if totalZoom > 1.1 {
2024-02-21 08:27:45 +01:00
var fixedGesture = gesture . translation
fixedGesture . width = fixedGesture . width / ( self . currentZoom + self . totalZoom )
fixedGesture . height = fixedGesture . height / ( self . currentZoom + self . totalZoom )
currentPos = fixedGesture
2024-02-20 20:57:50 +01:00
} else {
guard ! hasSwitch && attachments . count > 1 else { return }
if gesture . translation . width >= 40 || gesture . translation . width <= - 40 {
let currentIndex = attachments . firstIndex ( where : { $0 . id = = selectedId } ) ? ? 0
let newIndex = gesture . translation . width >= 20 ? loseIndex ( currentIndex - 1 , max : attachments . count - 1 ) : loseIndex ( currentIndex + 1 , max : attachments . count - 1 )
selectedId = attachments [ newIndex ] . id
hasSwitch = true
}
}
2024-02-18 18:39:07 +01:00
}
. onEnded { gesture in
totalPos . width += currentPos . width
totalPos . height += currentPos . height
currentPos = . zero
2024-02-20 20:57:50 +01:00
hasSwitch = false
2024-02-18 18:39:07 +01:00
}
)
. accessibilityZoomAction { action in
if action . direction = = . zoomIn {
totalZoom += 1
} else {
totalZoom -= 1
}
totalZoom = max ( 1 , totalZoom )
}
. overlay ( alignment : . topLeading ) {
Button {
dismiss ( )
} label : {
Text ( " attachment.close " )
. pill ( )
}
. padding ( )
}
. overlay ( alignment : . topTrailing ) {
HStack ( spacing : 10 ) {
Button {
readAlt . toggle ( )
} label : {
Text ( String ( " ALT " ) )
. font ( . body )
}
2024-02-24 13:24:14 +01:00
. realDisabled ( selectedAttachment ? . description ? . isEmpty ? ? true )
2024-02-18 18:39:07 +01:00
Divider ( )
. frame ( height : 10 )
Button {
2024-02-24 13:24:14 +01:00
guard AppDelegate . premium else { return }
2024-02-18 18:39:07 +01:00
Task {
let imgData = try await URLSession . shared . data ( from : selectedAttachment ? . url ? ? URL . placeholder )
if let img = UIImage ( data : imgData . 0 ) {
UIImageWriteToSavedPhotosAlbum ( img , nil , nil , nil )
}
}
} label : {
Image ( systemName : " square.and.arrow.down " )
}
2024-02-24 13:24:14 +01:00
. realDisabled ( ! AppDelegate . premium )
Divider ( )
. frame ( height : 10 )
Menu {
Button {
withAnimation ( . spring . speed ( 2.0 ) ) {
currentPos = . zero
totalPos = . zero
currentZoom = 0.0
totalZoom = 1.0
}
} label : {
Label ( " attachment.reset-move " , systemImage : " arrow.up.and.down.and.arrow.left.and.right " )
}
Divider ( )
Button {
let currentIndex = attachments . firstIndex ( where : { $0 . id = = selectedId } ) ? ? 0
let newIndex = loseIndex ( currentIndex - 1 , max : attachments . count - 1 )
selectedId = attachments [ newIndex ] . id
} label : {
Label ( " attachment.previous-image " , systemImage : " arrowshape.left " )
}
. disabled ( attachments . count <= 1 )
Button {
let currentIndex = attachments . firstIndex ( where : { $0 . id = = selectedId } ) ? ? 0
let newIndex = loseIndex ( currentIndex + 1 , max : attachments . count - 1 )
selectedId = attachments [ newIndex ] . id
} label : {
Label ( " attachment.next-image " , systemImage : " arrowshape.right " )
}
. disabled ( attachments . count <= 1 )
} label : {
Image ( systemName : " ellipsis " )
. frame ( height : 20 )
}
2024-02-18 18:39:07 +01:00
}
. pill ( )
. padding ( )
}
}
}
2024-02-20 20:57:50 +01:00
private func loseIndex ( _ index : Int , max : Int ) -> Int {
if index < 0 {
return max
} else if index > max {
return 0
}
return index
}
2024-02-18 18:39:07 +01:00
}
private extension View {
func pill ( ) -> some View {
self
. foregroundStyle ( Color ( uiColor : UIColor . label ) )
. padding ( . vertical , 7 )
. padding ( . horizontal , 10 )
. background ( Material . thin )
. clipShape ( Capsule ( ) )
}
2024-02-24 13:24:14 +01:00
func realDisabled ( _ bool : Bool ) -> some View {
self
. disabled ( bool )
. opacity ( bool ? 0.3 : 1.0 )
}
2024-02-18 18:39:07 +01:00
}
# Preview {
2024-03-07 17:22:11 +01:00
AttachmentView ( attachments : [ . init ( id : " ABC " , type : " image " , url : URL ( string : " https://i.stack.imgur.com/HX3Aj.png " ) , previewUrl : URL . placeholder , description : String ( " This displays the TabView with a page indicator at the bottom " ) , meta : nil ) , . init ( id : " DEF " , type : " image " , url : URL ( string : " https://cdn.pixabay.com/photo/2023/08/28/20/32/flower-8220018_1280.jpg " ) , previewUrl : URL ( string : " https://cdn.pixabay.com/photo/2023/08/28/20/32/flower-8220018_1280.jpg " ) , description : nil , meta : nil ) ] )
2024-02-18 18:39:07 +01:00
. environment ( AppDelegate ( ) )
}