├── .gitignore ├── LICENSE ├── MediaPickerExample ├── MediaPickerExample.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ └── xcshareddata │ │ └── xcschemes │ │ └── MediaPickerExample.xcscheme └── MediaPickerExample │ ├── AppDelegate.swift │ ├── Application.swift │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Chevron.imageset │ │ ├── Contents.json │ │ └── chevron.pdf │ ├── Contents.json │ ├── CustomGreen.colorset │ │ └── Contents.json │ └── CustomPurple.colorset │ │ └── Contents.json │ ├── ContentView.swift │ ├── CustomizedMediaPicker.swift │ ├── FilterMediaPicker.swift │ ├── Info.plist │ ├── MediaCell.swift │ └── Preview Content │ └── Preview Assets.xcassets │ └── Contents.json ├── Package.swift ├── README.md ├── Sources └── ExyteMediaPicker │ ├── Managers │ ├── FileManager.swift │ ├── Medias │ │ ├── AlbumMediasProvider.swift │ │ ├── AllMediasProvider.swift │ │ ├── AllPhotosProvider.swift │ │ ├── BaseMediasProvider.swift │ │ └── DefaultAlbumsProvider.swift │ ├── MotionManager.swift │ ├── PermissionsService.swift │ ├── PhotoKit │ │ ├── PHAsset+Utils.swift │ │ └── URL+Utils.swift │ └── Selection │ │ ├── CameraSelectionService.swift │ │ ├── SelectionParamsHolder.swift │ │ └── SelectionService.swift │ ├── Model │ ├── AlbumModel.swift │ ├── AssetMediaModel.swift │ ├── Media.swift │ ├── MediaModelProtocol.swift │ ├── Types.swift │ └── URLMediaModel.swift │ ├── Resources │ └── Media.xcassets │ │ ├── Colors │ │ ├── Contents.json │ │ ├── cameraBG.colorset │ │ │ └── Contents.json │ │ ├── cameraText.colorset │ │ │ └── Contents.json │ │ ├── pickerBG.colorset │ │ │ └── Contents.json │ │ ├── pickerText.colorset │ │ │ └── Contents.json │ │ └── selection.colorset │ │ │ └── Contents.json │ │ ├── Contents.json │ │ └── Images │ │ ├── Contents.json │ │ ├── FlashOff.imageset │ │ ├── Contents.json │ │ └── Flash.pdf │ │ ├── FlashOn.imageset │ │ ├── Contents.json │ │ └── Flash on.pdf │ │ └── FlipCamera.imageset │ │ ├── Change Camera.pdf │ │ └── Contents.json │ ├── Screens │ ├── AlbumView │ │ ├── AlbumView.swift │ │ ├── MediaCell.swift │ │ └── MediaViewModel.swift │ ├── AlbumsView │ │ ├── AlbumCell.swift │ │ ├── AlbumCellViewModel.swift │ │ ├── AlbumsView.swift │ │ └── AlbumsViewModel.swift │ ├── Camera │ │ ├── CameraSelectionContainer.swift │ │ ├── CameraView.swift │ │ ├── CameraViewModel.swift │ │ ├── LiveCameraCell.swift │ │ └── LiveCameraView.swift │ ├── Fullscreen │ │ ├── FullscreenCell.swift │ │ ├── FullscreenCellViewModel.swift │ │ └── FullscreenContainer.swift │ └── MediaPicker │ │ ├── AlbumSelectionView.swift │ │ ├── GenenricsTrick.swift │ │ ├── MediaPicker.swift │ │ ├── MediaPickerMode.swift │ │ └── MediaPickerViewModel.swift │ ├── Theme │ ├── Bundle+.swift │ └── MediaPickerTheme.swift │ └── Utils │ ├── Errors │ ├── PermissionActionView.swift │ └── PermissionsErrorView.swift │ ├── Extensions │ ├── Collection+.swift │ ├── ColumnCalculation.swift │ ├── OrientationTransformationExtensions.swift │ ├── Sequence+asyncMap.swift │ ├── TimeInterval+Duration.swift │ ├── View+.swift │ ├── View+NotificationCenter.swift │ └── Zoom+ScrollView.swift │ ├── Modifiers │ ├── KeyboardHeightHelper.swift │ ├── MediaButtonStyle.swift │ ├── MediaPickerThemeModifier.swift │ └── SafeAreaEnvironmentValues.swift │ ├── Thumbnail │ ├── ThumbnailPlaceholder.swift │ └── ThumbnailView.swift │ └── Widgets │ ├── AsyncButton.swift │ ├── CameraStubView.swift │ ├── LimitedLibraryPickerProxyView.swift │ ├── MediasGrid.swift │ ├── PlayerUIView.swift │ ├── SelectableView.swift │ └── SelectionIndicatorView.swift └── Tests └── MediaPickerTests ├── Extensions └── TimeInterval+Init.swift └── Tests └── Extensions └── TimeIntervalTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | **/xcuserdata/* 4 | Package.resolved 5 | .swiftpm/ 6 | .build/ 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 exyte 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample.xcodeproj/xcshareddata/xcschemes/MediaPickerExample.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 30 | 36 | 37 | 38 | 39 | 40 | 46 | 47 | 57 | 59 | 65 | 66 | 67 | 68 | 74 | 76 | 82 | 83 | 84 | 85 | 87 | 88 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Example-iOS 4 | // 5 | // Created by Alexandra Afonasova on 20.10.2022. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | final class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject { 12 | 13 | private var orientationLock = UIInterfaceOrientationMask.all 14 | 15 | func lockOrientationToPortrait() { 16 | orientationLock = .portrait 17 | if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene { 18 | scene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait)) 19 | } 20 | } 21 | 22 | func unlockOrientation() { 23 | orientationLock = .all 24 | if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene { 25 | let currentOrientation = UIDevice.current.orientation 26 | let newOrientation: UIInterfaceOrientationMask 27 | 28 | switch currentOrientation { 29 | case .portrait: newOrientation = .portrait 30 | case .portraitUpsideDown: newOrientation = .portraitUpsideDown 31 | case .landscapeLeft: newOrientation = .landscapeLeft 32 | case .landscapeRight: newOrientation = .landscapeRight 33 | default: newOrientation = .all 34 | } 35 | 36 | scene.requestGeometryUpdate(.iOS(interfaceOrientations: newOrientation)) 37 | } 38 | } 39 | 40 | func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { 41 | return orientationLock 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample/Application.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Example_iOSApp.swift 3 | // Example-iOS 4 | // 5 | // Created by Alex.M on 26.05.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct Application: App { 12 | 13 | @UIApplicationDelegateAdaptor var appDelegate: AppDelegate 14 | 15 | var body: some Scene { 16 | WindowGroup { 17 | ContentView() 18 | .environmentObject(appDelegate) 19 | .preferredColorScheme(.dark) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample/Assets.xcassets/Chevron.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "chevron.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample/Assets.xcassets/Chevron.imageset/chevron.pdf: -------------------------------------------------------------------------------- 1 | %PDF-1.7 2 | 3 | 1 0 obj 4 | << >> 5 | endobj 6 | 7 | 2 0 obj 8 | << /Length 3 0 R >> 9 | stream 10 | /DeviceRGB CS 11 | /DeviceRGB cs 12 | q 13 | -0.000000 -1.000000 1.000000 -0.000000 4.804688 7.000000 cm 14 | 0.486275 0.486275 0.486275 scn 15 | 0.292893 6.902419 m 16 | 0.683417 7.292943 1.316583 7.292943 1.707107 6.902419 c 17 | 6.707107 1.902419 l 18 | 7.097631 1.511895 7.097631 0.878730 6.707107 0.488206 c 19 | 1.707107 -4.511794 l 20 | 1.316583 -4.902319 0.683417 -4.902319 0.292893 -4.511794 c 21 | -0.097631 -4.121270 -0.097631 -3.488105 0.292893 -3.097581 c 22 | 4.585787 1.195312 l 23 | 0.292893 5.488206 l 24 | -0.097631 5.878730 -0.097631 6.511895 0.292893 6.902419 c 25 | h 26 | f 27 | n 28 | Q 29 | 30 | endstream 31 | endobj 32 | 33 | 3 0 obj 34 | 520 35 | endobj 36 | 37 | 4 0 obj 38 | << /Annots [] 39 | /Type /Page 40 | /MediaBox [ 0.000000 0.000000 12.000000 7.000000 ] 41 | /Resources 1 0 R 42 | /Contents 2 0 R 43 | /Parent 5 0 R 44 | >> 45 | endobj 46 | 47 | 5 0 obj 48 | << /Kids [ 4 0 R ] 49 | /Count 1 50 | /Type /Pages 51 | >> 52 | endobj 53 | 54 | 6 0 obj 55 | << /Pages 5 0 R 56 | /Type /Catalog 57 | >> 58 | endobj 59 | 60 | xref 61 | 0 7 62 | 0000000000 65535 f 63 | 0000000010 00000 n 64 | 0000000034 00000 n 65 | 0000000610 00000 n 66 | 0000000632 00000 n 67 | 0000000804 00000 n 68 | 0000000878 00000 n 69 | trailer 70 | << /ID [ (some) (id) ] 71 | /Root 6 0 R 72 | /Size 7 73 | >> 74 | startxref 75 | 937 76 | %%EOF -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample/Assets.xcassets/CustomGreen.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.000", 9 | "green" : "0.996", 10 | "red" : "0.788" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.000", 27 | "green" : "0.996", 28 | "red" : "0.788" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample/Assets.xcassets/CustomPurple.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.973", 9 | "green" : "0.396", 10 | "red" : "0.478" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.973", 27 | "green" : "0.396", 28 | "red" : "0.478" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Example-iOS 4 | // 5 | // Created by Alex.M on 26.05.2022. 6 | // 7 | 8 | import SwiftUI 9 | import ExyteMediaPicker 10 | 11 | struct ContentView: View { 12 | 13 | @EnvironmentObject private var appDelegate: AppDelegate 14 | 15 | @State private var showDefaultMediaPicker = false 16 | @State private var showCustomizedMediaPicker = false 17 | @State private var showFilterMediaPicker = false 18 | 19 | @State private var medias: [Media] = [] 20 | 21 | let columns = [GridItem(.flexible(), spacing: 1), 22 | GridItem(.flexible(), spacing: 1), 23 | GridItem(.flexible(), spacing: 1)] 24 | 25 | var body: some View { 26 | NavigationView { 27 | List { 28 | Section { 29 | Button("Default") { 30 | showDefaultMediaPicker = true 31 | } 32 | Button("Customized") { 33 | showCustomizedMediaPicker = true 34 | } 35 | Button("Filter") { 36 | showFilterMediaPicker = true 37 | } 38 | } 39 | 40 | if !medias.isEmpty { 41 | Section { 42 | LazyVGrid(columns: columns, spacing: 1) { 43 | ForEach(medias) { media in 44 | MediaCell(viewModel: MediaCellViewModel(media: media)) 45 | .aspectRatio(1, contentMode: .fill) 46 | } 47 | } 48 | } 49 | } 50 | } 51 | .foregroundColor(Color(uiColor: .label)) 52 | .navigationTitle("Examples") 53 | } 54 | 55 | // MARK: - Default media picker 56 | .fullScreenCover(isPresented: $showDefaultMediaPicker) { 57 | MediaPicker( 58 | isPresented: $showDefaultMediaPicker, 59 | onChange: { medias = $0 } 60 | ) 61 | //.showLiveCameraCell() 62 | .mediaSelectionType(.photoAndVideo) 63 | .mediaSelectionStyle(.count) 64 | .orientationHandler { 65 | switch $0 { 66 | case .lock: appDelegate.lockOrientationToPortrait() 67 | case .unlock: appDelegate.unlockOrientation() 68 | } 69 | } 70 | } 71 | 72 | // MARK: - Customized media picker 73 | .sheet(isPresented: $showCustomizedMediaPicker) { 74 | CustomizedMediaPicker(isPresented: $showCustomizedMediaPicker, medias: $medias) 75 | } 76 | 77 | // MARK: - Filter media picker 78 | .sheet(isPresented: $showFilterMediaPicker) { 79 | FilterMediaPicker(isPresented: $showFilterMediaPicker, medias: $medias) 80 | } 81 | } 82 | } 83 | 84 | struct ContentView_Previews: PreviewProvider { 85 | static var previews: some View { 86 | ContentView() 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample/CustomizedMediaPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 05.07.2022. 3 | // 4 | 5 | import Foundation 6 | import SwiftUI 7 | import ExyteMediaPicker 8 | 9 | struct CustomizedMediaPicker: View { 10 | 11 | @EnvironmentObject private var appDelegate: AppDelegate 12 | 13 | @Binding var isPresented: Bool 14 | @Binding var medias: [Media] 15 | 16 | @State private var albums: [Album] = [] 17 | 18 | @State private var mediaPickerMode = MediaPickerMode.photos 19 | @State private var selectedAlbum: Album? 20 | @State private var currentFullscreenMedia: Media? 21 | @State private var showAlbumsDropDown: Bool = false 22 | @State private var videoIsBeingRecorded: Bool = false 23 | 24 | let maxCount: Int = 5 25 | 26 | var body: some View { 27 | MediaPicker( 28 | isPresented: $isPresented, 29 | onChange: { medias = $0 }, 30 | albumSelectionBuilder: { _, albumSelectionView, _ in 31 | VStack { 32 | headerView 33 | albumSelectionView 34 | Spacer() 35 | footerView 36 | .background(Color.black) 37 | } 38 | .background(Color.black) 39 | }, 40 | cameraSelectionBuilder: { addMoreClosure, cancelClosure, cameraSelectionView in 41 | VStack { 42 | HStack { 43 | Spacer() 44 | Button("Done", action: { isPresented = false }) 45 | } 46 | .padding() 47 | cameraSelectionView 48 | HStack { 49 | Button("Cancel", action: cancelClosure) 50 | Spacer() 51 | Button(action: addMoreClosure) { 52 | Text("Take more photos") 53 | .greenButtonStyle() 54 | } 55 | } 56 | .padding() 57 | } 58 | .background(Color.black) 59 | }, 60 | cameraViewBuilder: { cameraSheetView, cancelClosure, showPreviewClosure, takePhotoClosure, startVideoCaptureClosure, stopVideoCaptureClosure, _, _ in 61 | cameraSheetView 62 | .overlay(alignment: .topLeading) { 63 | HStack { 64 | Button("Cancel") { cancelClosure() } 65 | .foregroundColor(Color("CustomPurple")) 66 | Spacer() 67 | Button("Done") { showPreviewClosure() } 68 | .foregroundColor(Color("CustomPurple")) 69 | } 70 | .padding() 71 | } 72 | .overlay(alignment: .bottom) { 73 | HStack { 74 | Button("Take photo") { takePhotoClosure() } 75 | .greenButtonStyle() 76 | Button(videoIsBeingRecorded ? "Stop video capture" : "Capture video") { 77 | videoIsBeingRecorded ? stopVideoCaptureClosure() : startVideoCaptureClosure() 78 | videoIsBeingRecorded.toggle() 79 | } 80 | .greenButtonStyle() 81 | } 82 | .padding() 83 | } 84 | } 85 | ) 86 | .showLiveCameraCell() 87 | .albums($albums) 88 | .pickerMode($mediaPickerMode) 89 | .currentFullscreenMedia($currentFullscreenMedia) 90 | .orientationHandler { 91 | switch $0 { 92 | case .lock: appDelegate.lockOrientationToPortrait() 93 | case .unlock: appDelegate.unlockOrientation() 94 | } 95 | } 96 | .mediaSelectionStyle(.count) 97 | .mediaSelectionLimit(maxCount) 98 | .mediaPickerTheme( 99 | main: .init( 100 | pickerBackground: .black, 101 | fullscreenPhotoBackground: .black 102 | ), 103 | selection: .init( 104 | cellSelectedBorder: .customPurple, 105 | cellSelectedBackground: .customPurple, 106 | fullscreenEmptyBorder: .customPurple, 107 | fullscreenSelectedBorder: .customPurple, 108 | fullscreenSelectedBackground: .customPurple) 109 | ) 110 | .overlay(alignment: .topLeading) { 111 | if showAlbumsDropDown { 112 | albumsDropdown 113 | .background(Color.white) 114 | .foregroundColor(.black) 115 | .cornerRadius(5) 116 | } 117 | } 118 | .background(Color.black) 119 | .foregroundColor(.white) 120 | } 121 | 122 | var headerView: some View { 123 | HStack { 124 | HStack { 125 | Text(selectedAlbum?.title ?? "Recents") 126 | Image(systemName: "chevron.down") 127 | .rotationEffect(Angle(radians: showAlbumsDropDown ? .pi : 0)) 128 | } 129 | .onTapGesture { 130 | withAnimation { 131 | showAlbumsDropDown.toggle() 132 | } 133 | } 134 | 135 | Spacer() 136 | 137 | Text("\(medias.count) out of \(maxCount) selected") 138 | } 139 | .padding() 140 | } 141 | 142 | var footerView: some View { 143 | HStack { 144 | TextField("", text: .constant(""), prompt: Text("Add a caption").foregroundColor(.gray)) 145 | .padding() 146 | .background { 147 | Color.white.opacity(0.2) 148 | .cornerRadius(6) 149 | } 150 | 151 | Spacer(minLength: 70) 152 | 153 | Button { 154 | isPresented = false 155 | } label: { 156 | HStack { 157 | Text("Add") 158 | 159 | Text("\(medias.count)") 160 | .padding(6) 161 | .background(Color.white) 162 | .clipShape(Circle()) 163 | } 164 | .frame(maxWidth: .infinity) 165 | } 166 | .greenButtonStyle() 167 | } 168 | .padding(.horizontal) 169 | .padding(.vertical, 8) 170 | } 171 | 172 | var albumsDropdown: some View { 173 | ScrollView { 174 | VStack(alignment: .leading, spacing: 10) { 175 | ForEach(albums) { album in 176 | Button(album.title ?? "") { 177 | selectedAlbum = album 178 | mediaPickerMode = .album(album) 179 | showAlbumsDropDown = false 180 | } 181 | } 182 | } 183 | .padding(15) 184 | } 185 | .frame(maxHeight: 300) 186 | } 187 | } 188 | 189 | extension View { 190 | func greenButtonStyle() -> some View { 191 | self.font(.headline) 192 | .foregroundColor(.black) 193 | .padding() 194 | .background { 195 | Color("CustomGreen") 196 | .cornerRadius(16) 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample/FilterMediaPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilterMediaPicker.swift 3 | // Example-iOS 4 | // 5 | // Created by Alisa Mylnikova on 18.05.2023. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import ExyteMediaPicker 11 | 12 | struct FilterMediaPicker: View { 13 | 14 | @Binding var isPresented: Bool 15 | @Binding var medias: [Media] 16 | 17 | var body: some View { 18 | MediaPicker( 19 | isPresented: $isPresented, 20 | onChange: { medias = $0 } 21 | ) 22 | .applyFilter { await isMostlyRed($0) } 23 | } 24 | 25 | private func isMostlyRed(_ media: Media) async -> Media? { 26 | guard let data = await media.getThumbnailData() else { return nil } 27 | guard let uiImage = UIImage(data: data) else { return nil } 28 | 29 | let color = uiImage.averageColor 30 | var red: CGFloat = 0.0, green: CGFloat = 0.0, blue: CGFloat = 0.0, alpha: CGFloat = 0.0 31 | color?.getRed(&red, green: &green, blue: &blue, alpha: &alpha) 32 | if red > blue, red > green { 33 | return media 34 | } else { 35 | return nil 36 | } 37 | } 38 | } 39 | 40 | extension UIImage { 41 | var averageColor: UIColor? { 42 | guard let inputImage = CIImage(image: self) else { return nil } 43 | let extentVector = CIVector(x: inputImage.extent.origin.x, y: inputImage.extent.origin.y, z: inputImage.extent.size.width, w: inputImage.extent.size.height) 44 | 45 | guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: inputImage, kCIInputExtentKey: extentVector]) else { return nil } 46 | guard let outputImage = filter.outputImage else { return nil } 47 | 48 | var bitmap = [UInt8](repeating: 0, count: 4) 49 | let context = CIContext(options: [.workingColorSpace: kCFNull as Any]) 50 | context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) 51 | 52 | return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample/MediaCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaCell.swift 3 | // Example-iOS 4 | // 5 | // Created by Alisa Mylnikova on 21.04.2023. 6 | // 7 | 8 | import SwiftUI 9 | import ExyteMediaPicker 10 | import AVFoundation 11 | import _AVKit_SwiftUI 12 | 13 | struct MediaCell: View { 14 | 15 | @StateObject var viewModel: MediaCellViewModel 16 | 17 | var body: some View { 18 | GeometryReader { g in 19 | VStack { 20 | if let url = viewModel.imageUrl { 21 | AsyncImage(url: url) { phase in 22 | if case let .success(image) = phase { 23 | image 24 | .resizable() 25 | .scaledToFill() 26 | } 27 | } 28 | } else if let player = viewModel.player { 29 | VideoPlayer(player: player).onTapGesture { 30 | viewModel.togglePlay() 31 | } 32 | } else { 33 | ProgressView() 34 | } 35 | } 36 | .frame(width: g.size.width, height: g.size.height) 37 | } 38 | .clipped() 39 | .task { 40 | await viewModel.onStart() 41 | } 42 | .onDisappear { 43 | viewModel.onStop() 44 | } 45 | } 46 | } 47 | 48 | @MainActor 49 | final class MediaCellViewModel: ObservableObject { 50 | 51 | let media: Media 52 | 53 | @Published var imageUrl: URL? = nil 54 | @Published var player: AVPlayer? = nil 55 | 56 | private var isPlaying = false 57 | 58 | init(media: Media) { 59 | self.media = media 60 | } 61 | 62 | func onStart() async { 63 | guard imageUrl == nil || player == nil else { return } 64 | 65 | let url = await media.getURL() 66 | guard let url = url else { return } 67 | 68 | DispatchQueue.main.async { [weak self, media] in 69 | switch media.type { 70 | case .image: 71 | self?.imageUrl = url 72 | case .video: 73 | self?.player = AVPlayer(url: url) 74 | } 75 | } 76 | } 77 | 78 | func togglePlay() { 79 | if isPlaying { 80 | player?.pause() 81 | } else { 82 | player?.play() 83 | } 84 | isPlaying = !isPlaying 85 | } 86 | 87 | func onStop() { 88 | imageUrl = nil 89 | player = nil 90 | isPlaying = false 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /MediaPickerExample/MediaPickerExample/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "ExyteMediaPicker", 7 | platforms: [ 8 | .iOS(.v17) 9 | ], 10 | products: [ 11 | .library( 12 | name: "ExyteMediaPicker", 13 | targets: ["ExyteMediaPicker"]), 14 | ], 15 | dependencies: [ 16 | .package( 17 | url: "https://github.com/exyte/AnchoredPopup.git", 18 | from: "1.0.0" 19 | ) 20 | ], 21 | targets: [ 22 | .target( 23 | name: "ExyteMediaPicker", 24 | dependencies: [ 25 | .product(name: "AnchoredPopup", package: "AnchoredPopup") 26 | ], 27 | swiftSettings: [ 28 | .enableExperimentalFeature("StrictConcurrency") 29 | ] 30 | ), 31 | .testTarget( 32 | name: "MediaPickerTests", 33 | dependencies: ["ExyteMediaPicker"]), 34 | ] 35 | ) 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |       4 | 5 | 6 | 7 | 8 |

Media Picker

9 | 10 |

SwiftUI library for a customizable media picker.

11 | 12 | ![](https://img.shields.io/github/v/tag/exyte/MediaPicker?label=Version) 13 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fexyte%2FMediaPicker%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/exyte/MediaPicker) 14 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fexyte%2FMediaPicker%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/exyte/MediaPicker) 15 | [![SPM](https://img.shields.io/badge/SPM-Compatible-brightgreen.svg)](https://swiftpackageindex.com/exyte/MediaPicker) 16 | [![Cocoapods](https://img.shields.io/badge/Cocoapods-Deprecated%20after%202.2.3-yellow.svg)](https://cocoapods.org/pods/ExyteMediaPicker) 17 | [![License: MIT](https://img.shields.io/badge/License-MIT-black.svg)](https://opensource.org/licenses/MIT) 18 | 19 | # Features 20 | * Photo and video picker 21 | * Single and multiple selection 22 | * Full-screen view 23 | * Live photo preview & capture 24 | * Full customization 25 | 26 | # MediaPicker vs PhotosPicker 27 | * The iOS 16 PhotosPicker only provides you with a button, while this library gives you the whole view, meaning you can build it into you own screens and customize it as you see fit. 28 | * PhotosPicker only lets you pick photos from the library - no camera. 29 | * MediaPicker provides a default looking library picker, with ability to manage albums, and also a camera view to take photos/video 30 | 31 | ## !SPM Renaming! 32 | SPM package is now called `ExyteMediaPicker` instead of `MediaPicker`, sorry for any inconveniences. 33 | 34 | # Usage 35 | 1. Add a binding Bool to control the picker presentation state. 36 | 2. Add Media array to save selection (`[Media]`). 37 | 3. Initialize the media picker and present it however you like - for example, using the .sheet modifier 38 | ```swift 39 | .sheet(isPresented: $showMediaPicker) { 40 | MediaPicker( 41 | isPresented: $showMediaPicker, 42 | onChange: { medias = $0 } 43 | ) 44 | } 45 | ``` 46 | 47 | ## Media model 48 | The lbrary will return an array of `Media` structs for you to use as you see fit. It has the following fields and methods (all the methods use async/await API): 49 | - `type` - .image or .video 50 | - `duration` - nil for .image 51 | - `getURL()` - returns `URL` to media (automatically stores in temporary directory if needed) 52 | - `getThumbnailURL()` - returns `URL` to media's thumbnail (for image just returns the image itself) 53 | - `getData()` - returns media's `Data` representation 54 | - `getThumbnailData()` - returns media's thumbnail `Data` representation 55 | 56 | ## Modes 57 | This library lets you use both photo library and camera 58 | 59 | ### Photos grid 60 | Default photos grid screen has a standard header which contains the 'Done' and 'Cancel' buttons, and a simple switcher between Photos and Albums. Use it for a basic out-of-the box picker (see default picker in example project for an usage example). This can be customized (see "Init - view builders" section) 61 | 62 | ### Camera 63 | After making one photo, you see a preview of it and a little plus icon, by tapping it you return back to camera mode and can continue making as many photos as you like. Press "Done" once you're finished and you will be able to scroll through all the photos you've taken before confirming you'd like to use them. This preview screen of photos you've taken can also be customized (see "Init - view builders" section) 64 | 65 | ## Init - required parameters 66 | `isPresented` - a binding to determine whether the picker should be displayed or not 67 | `onChange` - a closure that returns the selected media every time the selection changes 68 | 69 | ### Init - optional view builders 70 | You can pass 1-3 view builders in order to add your own buttons and other elements to media picker screens. You can pass all, some or none of these when creating your `MediaPicker` (see the custom picker in the example project for usage example). First screen you can customize is default photos grid view. Pass `albumSelectionBuilder` closure like this to replace the standard one with your own view: 71 | ```swift 72 | MediaPicker( 73 | isPresented: $isPresented, 74 | onChange: { selectedMedia = $0 }, 75 | albumSelectionBuilder: { defaultHeaderView, albumSelectionView, isInFullscreen in 76 | VStack { 77 | if !isInFullscreen { 78 | defaultHeaderView 79 | } 80 | albumSelectionView 81 | Spacer() 82 | footerView 83 | } 84 | .background(Color.black) 85 | } 86 | ) 87 | ``` 88 | 89 | `albumSelectionBuilder` gives you two views to work with: 90 | - `defaultHeaderView` - a default looking `header` with photos/albums mode switcher 91 | - `albumSelectionView` - the photos grid itself 92 | - `isInFullscreen` - is fullscreen photo details screen displayed. Use for example to hide the header while in fullscreen mode. 93 | 94 | The second customizable screen is the one you see after taking a photo. Pass `cameraSelectionBuilder` like this: 95 | ```swift 96 | MediaPicker( 97 | isPresented: $isPresented, 98 | onChange: { selectedMedia = $0 }, 99 | cameraSelectionBuilder: { addMoreClosure, cancelClosure, cameraSelectionView in 100 | VStack { 101 | HStack { 102 | Spacer() 103 | Button("Done", action: { isPresented = false }) 104 | } 105 | cameraSelectionView 106 | HStack { 107 | Button("Cancel", action: cancelClosure) 108 | Spacer() 109 | Button(action: addMoreClosure) { 110 | Text("Take more photos") 111 | } 112 | } 113 | } 114 | } 115 | ) 116 | ``` 117 | `cameraSelectionBuilder` gives you these parameters: 118 | - `addMoreClosure` - you can call this closure on tap of your own button, it will work same as default plus icon on camera selection preview screen 119 | - `cancelClosure` - show confirmation and return to photos grid screen if confirmed 120 | - `cameraSelectionView` - swipable camera photos preview collection itself 121 | 122 | The last one is live camera screen 123 | 124 | ```swift 125 | MediaPicker( 126 | isPresented: $isPresented, 127 | onChange: { selectedMedia = $0 }, 128 | cameraViewBuilder: { cameraSheetView, cancelClosure, showPreviewClosure, takePhotoClosure, startVideoCaptureClosure, stopVideoCaptureClosure, toggleFlash, flipCamera in 129 | cameraSheetView 130 | .overlay(alignment: .topLeading) { 131 | HStack { 132 | Button("Cancel") { cancelClosure() } 133 | .foregroundColor(Color("CustomPurple")) 134 | Spacer() 135 | Button("Done") { showPreviewClosure() } 136 | .foregroundColor(Color("CustomPurple")) 137 | } 138 | .padding() 139 | } 140 | .overlay(alignment: .bottom) { 141 | HStack { 142 | Button("Take photo") { takePhotoClosure() } 143 | .greenButtonStyle() 144 | Button(videoIsBeingRecorded ? "Stop video capture" : "Capture video") { 145 | videoIsBeingRecorded ? stopVideoCaptureClosure() : startVideoCaptureClosure() 146 | videoIsBeingRecorded.toggle() 147 | } 148 | .greenButtonStyle() 149 | } 150 | .padding() 151 | } 152 | } 153 | ) 154 | ``` 155 | 156 | `cameraViewBuilder` live camera capture view and a lot of closures to do with as you please: 157 | - `cameraSheetView` - live camera capture view 158 | - `cancelClosure` - if you want to display "are you sure" before closing 159 | - `showPreviewClosure` - shows preview of taken photos 160 | - `cancelClosure` - if you want to display "are you sure" before closing 161 | - `startVideoCaptureClosure` - starts video capture, you'll need a bollean varialbe to track recording state 162 | - `stopVideoCaptureClosure` - stops video capture 163 | - `toggleFlash` - flash off/on 164 | - `flipCamera` - camera back/front 165 | 166 | ## Available modifiers 167 | `showLiveCameraCell` - show live camera feed cell in the top left corner of the gallery grid 168 | `mediaSelectionType` - limit displayed media type: .photo, .video or both 169 | `mediaSelectionStyle` - a way to display selected/unselected media state: a counter or a simple checkmark 170 | `mediaSelectionLimit` - the maximum selection quantity allowed, 'nil' for unlimited selection 171 | `showFullscreenPreview` - if true - tap on media opens fullscreen preview, if false - tap on image immediately selects this image and closes the picker 172 | 173 | ### Available modifiers - filtering 174 | `applyFilter((Media) async -> Media?)` - pass a closure to apply to each of medias individually. Closures's return type is `Media?`: return `Media` the closure gives to you if you want it to be displayed on photo grid, or `nil` if you want to exclude it. The code you apply to each media can be asyncronous (using async/await syntactics, check out `FilterMediaPicker` in example project) 175 | `applyFilter(([Media]) async -> [Media])` - same but apply the closure to whole medias array. Can also be used for reodering. 176 | 177 | ### Available modifiers - screen rotation 178 | If your app restricts screen rotation, you can skip this section. 179 | 180 | We recommend locking orientation for MediaPicker, because default rotation animations don't look good on the camera screen. At the moment SwiftUI doesn't provide a way of locking screen orientation, so the library has an initializer with an `orientationHandler` parameter - a closure that is called when you enter/leave the camera screen inside MediaPicker. In this closure you need to use AppDelegate to lock/unlock the rotation - see example project for implementation. 181 | 182 | ### Available modifiers: managing albums 183 | `albums` - a list of user's albums (like in Photos app), if you want to display them differently than `showingDefaultHeader` does. 184 | `pickerMode` - set this if you don't plan to use the default header. Available options are: 185 | * .photos - displays the default photos grid 186 | * .albums - displays a list of albums with one preview photo for each 187 | * .album(Album) - displays one album 188 | * .camera - shows a fullscreen cover camera sheet 189 | * .cameraSelection - displays a preview of photos taken with camera 190 | (see the custom picker in the example project for implementation) 191 | 192 | 193 | 194 | ### Available modifiers: theme 195 | `mediaPickerTheme` - color settings. Example usage (see `MediaPickerTheme` for all available settings): 196 | ```swift 197 | MediaPicker(...) 198 | .mediaPickerTheme( 199 | main: .init( 200 | background: .black 201 | ), 202 | selection: .init( 203 | emptyTint: .white, 204 | emptyBackground: .black.opacity(0.25), 205 | selectedTint: Color("CustomPurple") 206 | ) 207 | ) 208 | ``` 209 | Here is an example of how you can customize colors and elements to create a custom looking picker: 210 | 211 | 212 | 213 | ## Examples 214 | 215 | To try out the MediaPicker examples: 216 | - Clone the repo `https://github.com/exyte/MediaPicker.git` 217 | - Open `MediaPickerExample.xcodeproj` in the Xcode 218 | - Try it! 219 | 220 | ## Installation 221 | 222 | ### [Swift Package Manager](https://swift.org/package-manager/) 223 | 224 | ```swift 225 | dependencies: [ 226 | .package(url: "https://github.com/exyte/ExyteMediaPicker.git") 227 | ] 228 | ``` 229 | 230 | ## Requirements 231 | 232 | * iOS 17+ 233 | * Xcode 15+ 234 | 235 | ## Our other open source SwiftUI libraries 236 | [PopupView](https://github.com/exyte/PopupView) - Toasts and popups library 237 | [AnchoredPopup](https://github.com/exyte/AnchoredPopup) - Anchored Popup grows "out" of a trigger view (similar to Hero animation) 238 | [Grid](https://github.com/exyte/Grid) - The most powerful Grid container 239 | [ScalingHeaderScrollView](https://github.com/exyte/ScalingHeaderScrollView) - A scroll view with a sticky header which shrinks as you scroll 240 | [AnimatedTabBar](https://github.com/exyte/AnimatedTabBar) - A tabbar with a number of preset animations 241 | [Chat](https://github.com/exyte/chat) - Chat UI framework with fully customizable message cells, input view, and a built-in media picker 242 | [OpenAI](https://github.com/exyte/OpenAI) Wrapper lib for [OpenAI REST API](https://platform.openai.com/docs/api-reference/introduction) 243 | [AnimatedGradient](https://github.com/exyte/AnimatedGradient) - Animated linear gradient 244 | [ConcentricOnboarding](https://github.com/exyte/ConcentricOnboarding) - Animated onboarding flow 245 | [FloatingButton](https://github.com/exyte/FloatingButton) - Floating button menu 246 | [ActivityIndicatorView](https://github.com/exyte/ActivityIndicatorView) - A number of animated loading indicators 247 | [ProgressIndicatorView](https://github.com/exyte/ProgressIndicatorView) - A number of animated progress indicators 248 | [FlagAndCountryCode](https://github.com/exyte/FlagAndCountryCode) - Phone codes and flags for every country 249 | [SVGView](https://github.com/exyte/SVGView) - SVG parser 250 | [LiquidSwipe](https://github.com/exyte/LiquidSwipe) - Liquid navigation animation 251 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Managers/FileManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileManager.swift 3 | // 4 | // 5 | // Created by Alisa Mylnikova on 12.07.2022. 6 | // 7 | 8 | import SwiftUI 9 | import AVFoundation 10 | 11 | extension FileManager { 12 | 13 | static var tempPath: URL { 14 | URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) 15 | } 16 | 17 | static var imageFileExtension: String { ".jpg" } 18 | static var videoFileExtension: String { ".mp4" } 19 | 20 | static func storeToTempDir(url: URL) -> URL { 21 | let id = UUID().uuidString 22 | let path = FileManager.tempPath.appendingPathComponent(id + Self.imageFileExtension) 23 | 24 | try? FileManager.default.copyItem(at: url, to: path) 25 | return path 26 | } 27 | 28 | static func storeToTempDir(data: Data) -> URL { 29 | let id = UUID().uuidString 30 | let path = FileManager.tempPath.appendingPathComponent(id + Self.imageFileExtension) 31 | 32 | try? data.write(to: path) 33 | return path 34 | } 35 | 36 | static func getTempUrl() -> URL { 37 | let id = UUID().uuidString 38 | return FileManager.tempPath.appendingPathComponent(id + Self.videoFileExtension) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Managers/Medias/AlbumMediasProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 09.06.2022. 3 | // 4 | 5 | import Foundation 6 | import Photos 7 | import SwiftUI 8 | 9 | final class AlbumMediasProvider: BaseMediasProvider { 10 | 11 | let album: AlbumModel 12 | 13 | init(album: AlbumModel, selectionParamsHolder: SelectionParamsHolder, filterClosure: MediaPicker.FilterClosure? = nil, massFilterClosure: MediaPicker.MassFilterClosure? = nil) { 14 | self.album = album 15 | super.init(selectionParamsHolder: selectionParamsHolder, filterClosure: filterClosure, massFilterClosure: massFilterClosure) 16 | } 17 | 18 | override func reload() { 19 | PermissionsService.shared.requestPhotoLibraryPermission { 20 | DispatchQueue.main.async { [weak self] in 21 | self?.reloadInternal() 22 | } 23 | } 24 | } 25 | 26 | func reloadInternal() { 27 | isLoading = true 28 | defer { 29 | isLoading = false 30 | } 31 | let fetchOptions = PHFetchOptions() 32 | fetchOptions.sortDescriptors = [ 33 | NSSortDescriptor(key: "creationDate", ascending: false) 34 | ] 35 | let fetchResult = PHAsset.fetchAssets(in: album.source, options: fetchOptions) 36 | if fetchResult.count == 0 { 37 | assetMediaModels = [] 38 | } 39 | 40 | let assets = MediasProvider.map(fetchResult: fetchResult, mediaSelectionType: selectionParamsHolder.mediaType) 41 | filterAndPublish(assets: assets) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Managers/Medias/AllMediasProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 09.06.2022. 3 | // 4 | 5 | import Foundation 6 | 7 | import Foundation 8 | import Photos 9 | import SwiftUI 10 | 11 | final class AllMediasProvider: BaseMediasProvider { 12 | 13 | override func reload() { 14 | PermissionsService.shared.requestPhotoLibraryPermission { 15 | DispatchQueue.main.async { [weak self] in 16 | self?.reloadInternal() 17 | } 18 | } 19 | } 20 | 21 | func reloadInternal() { 22 | isLoading = true 23 | defer { 24 | isLoading = false 25 | } 26 | let allPhotosOptions = PHFetchOptions() 27 | allPhotosOptions.sortDescriptors = [ 28 | NSSortDescriptor(key: "creationDate", ascending: false) 29 | ] 30 | let allPhotos = PHAsset.fetchAssets(with: allPhotosOptions) 31 | let assets = MediasProvider.map(fetchResult: allPhotos, mediaSelectionType: selectionParamsHolder.mediaType) 32 | filterAndPublish(assets: assets) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Managers/Medias/AllPhotosProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 09.06.2022. 3 | // 4 | 5 | import Foundation 6 | 7 | import Foundation 8 | import Photos 9 | import SwiftUI 10 | 11 | @MainActor 12 | final class AllPhotosProvider: BaseMediasProvider { 13 | 14 | override func reload() { 15 | PermissionsService.shared.requestPhotoLibraryPermission { 16 | DispatchQueue.main.async { [weak self] in 17 | self?.reloadInternal() 18 | } 19 | } 20 | } 21 | 22 | func reloadInternal() { 23 | let allPhotosOptions = PHFetchOptions() 24 | allPhotosOptions.sortDescriptors = [ 25 | NSSortDescriptor(key: "creationDate", ascending: false) 26 | ] 27 | let allPhotos = PHAsset.fetchAssets(with: allPhotosOptions) 28 | let assets = MediasProvider.map(fetchResult: allPhotos, mediaSelectionType: selectionParamsHolder.mediaType) 29 | filterAndPublish(assets: assets) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Managers/Medias/BaseMediasProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 09.06.2022. 3 | // 4 | 5 | import Foundation 6 | import Photos 7 | import SwiftUI 8 | 9 | @MainActor 10 | class BaseMediasProvider: ObservableObject { 11 | var selectionParamsHolder: SelectionParamsHolder 12 | var filterClosure: MediaPicker.FilterClosure? 13 | var massFilterClosure: MediaPicker.MassFilterClosure? 14 | 15 | @Published var assetMediaModels = [AssetMediaModel]() 16 | private var privateAssetMediaModels: [AssetMediaModel] = [] 17 | 18 | @Published var isLoading: Bool = false 19 | 20 | private var timerTask: Task? 21 | private var cancellableTask: Task? 22 | 23 | init(selectionParamsHolder: SelectionParamsHolder, filterClosure: MediaPicker.FilterClosure?, massFilterClosure: MediaPicker.MassFilterClosure?) { 24 | self.selectionParamsHolder = selectionParamsHolder 25 | self.filterClosure = filterClosure 26 | self.massFilterClosure = massFilterClosure 27 | } 28 | 29 | func filterAndPublish(assets: [AssetMediaModel]) { 30 | isLoading = true 31 | defer { 32 | isLoading = false 33 | } 34 | 35 | if let filterClosure = filterClosure { 36 | startPublishing() 37 | 38 | cancellableTask = Task { [weak self] in 39 | let serialQueue = DispatchQueue(label: "filterSerialQueue") 40 | self?.privateAssetMediaModels = [AssetMediaModel]() 41 | 42 | await withTaskGroup(of: AssetMediaModel?.self) { group in 43 | for asset in assets { 44 | group.addTask { 45 | if Task.isCancelled { return nil } 46 | 47 | let media = await Task.detached(priority: .userInitiated) { 48 | return await filterClosure(Media(source: asset)) 49 | }.value 50 | 51 | return media?.source as? AssetMediaModel 52 | } 53 | } 54 | 55 | for await filteredMedia in group { 56 | if let model = filteredMedia { 57 | serialQueue.sync { 58 | self?.privateAssetMediaModels.append(model) 59 | } 60 | } 61 | } 62 | } 63 | 64 | self?.stopPublishing() 65 | DispatchQueue.main.async { 66 | self?.assetMediaModels = self?.privateAssetMediaModels ?? [] 67 | } 68 | } 69 | } else if let massFilterClosure = massFilterClosure { 70 | cancellableTask = Task { [weak self] in 71 | let result = await massFilterClosure(assets.map { Media(source: $0) }) 72 | self?.assetMediaModels = result.compactMap { $0.source as? AssetMediaModel } 73 | } 74 | } 75 | else { 76 | DispatchQueue.main.async { [weak self] in 77 | self?.assetMediaModels = assets 78 | } 79 | } 80 | } 81 | 82 | func startPublishing() { 83 | // Start a task that runs every second 84 | timerTask = Task { 85 | while !Task.isCancelled { 86 | try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second 87 | 88 | await MainActor.run { 89 | self.assetMediaModels = self.privateAssetMediaModels 90 | } 91 | } 92 | } 93 | } 94 | 95 | func stopPublishing() { 96 | timerTask?.cancel() 97 | } 98 | 99 | func reload() { } 100 | 101 | func cancel() { 102 | cancellableTask?.cancel() 103 | stopPublishing() 104 | } 105 | } 106 | 107 | class MediasProvider { 108 | 109 | static func map(fetchResult: PHFetchResult, mediaSelectionType: MediaSelectionType) -> [AssetMediaModel] { 110 | var assetMediaModels: [AssetMediaModel] = [] 111 | 112 | if fetchResult.count == 0 { 113 | return assetMediaModels 114 | } 115 | 116 | for index in 0...(fetchResult.count - 1) { 117 | let asset = fetchResult[index] 118 | if (asset.mediaType == .image && mediaSelectionType.allowsPhoto) || (asset.mediaType == .video && mediaSelectionType.allowsVideo) { 119 | assetMediaModels.append(AssetMediaModel(asset: asset)) 120 | } 121 | } 122 | return assetMediaModels 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Managers/Medias/DefaultAlbumsProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 10.06.2022. 3 | // 4 | 5 | import Foundation 6 | import Photos 7 | 8 | import Photos 9 | import SwiftUI 10 | 11 | @MainActor 12 | final class DefaultAlbumsProvider: ObservableObject { 13 | 14 | @Published private(set) var albums: [AlbumModel] = [] 15 | @Published private(set) var isLoading: Bool = false 16 | 17 | var mediaSelectionType: MediaSelectionType = .photoAndVideo 18 | private var reloadTask: Task? 19 | 20 | func reload() { 21 | cancelReload() 22 | 23 | PermissionsService.shared.requestPhotoLibraryPermission { [weak self] in 24 | guard let self = self else { return } 25 | DispatchQueue.main.async { 26 | self.reloadTask = Task { 27 | self.isLoading = true 28 | await self.reloadInternal() 29 | self.isLoading = false 30 | } 31 | } 32 | } 33 | } 34 | 35 | func cancelReload() { 36 | reloadTask?.cancel() 37 | reloadTask = nil 38 | } 39 | 40 | private func reloadInternal() async { 41 | let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum] 42 | var allAlbums: [AlbumModel] = [] 43 | 44 | for type in albumTypes { 45 | if Task.isCancelled { return } 46 | let albums = fetchAlbums(type: type) 47 | allAlbums.append(contentsOf: albums) 48 | } 49 | 50 | if Task.isCancelled { return } 51 | self.albums = allAlbums 52 | } 53 | 54 | private func fetchAlbums(type: PHAssetCollectionType) -> [AlbumModel] { 55 | let options = PHFetchOptions() 56 | options.includeAssetSourceTypes = [.typeUserLibrary, .typeiTunesSynced, .typeCloudShared] 57 | options.sortDescriptors = [NSSortDescriptor(key: "localizedTitle", ascending: true)] 58 | 59 | let collections = PHAssetCollection.fetchAssetCollections( 60 | with: type, 61 | subtype: .any, 62 | options: options 63 | ) 64 | 65 | guard collections.count > 0 else { return [] } 66 | 67 | var albums: [AlbumModel] = [] 68 | 69 | for index in 0.. 0 else { continue } 81 | 82 | let preview = MediasProvider.map(fetchResult: fetchResult, mediaSelectionType: mediaSelectionType).first 83 | let album = AlbumModel(preview: preview, source: collection) 84 | albums.append(album) 85 | } 86 | return albums 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Managers/MotionManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MotionManager.swift 3 | // 4 | // 5 | // Created by Alexandra Afonasova on 20.10.2022. 6 | // 7 | 8 | import CoreMotion 9 | import UIKit 10 | 11 | final class MotionManager { 12 | 13 | private(set) var orientation: UIDeviceOrientation = .unknown 14 | 15 | private let motionManager = CMMotionManager() 16 | 17 | init() { 18 | motionManager.accelerometerUpdateInterval = 0.2 19 | motionManager.gyroUpdateInterval = 0.2 20 | 21 | guard let queue = OperationQueue.current else { return } 22 | motionManager.startAccelerometerUpdates(to: queue) { [weak self] data, _ in 23 | guard let data = data else { return } 24 | self?.resolveOrientation(from: data) 25 | } 26 | } 27 | 28 | deinit { 29 | motionManager.stopAccelerometerUpdates() 30 | } 31 | 32 | private func resolveOrientation(from data: CMAccelerometerData) { 33 | let acceleration = data.acceleration 34 | var orientation: UIDeviceOrientation = self.orientation 35 | 36 | if acceleration.x >= 0.75 { 37 | orientation = .landscapeRight 38 | } else if acceleration.x <= -0.75 { 39 | orientation = .landscapeLeft 40 | } else if acceleration.y <= -0.75 { 41 | orientation = .portrait 42 | } else if acceleration.y >= 0.75 { 43 | orientation = .portraitUpsideDown 44 | } 45 | 46 | if orientation == self.orientation { return } 47 | self.orientation = orientation 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Managers/PermissionsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 08.06.2022. 3 | // 4 | 5 | import Foundation 6 | import Combine 7 | import AVFoundation 8 | import Photos 9 | 10 | @MainActor 11 | final class PermissionsService: ObservableObject { 12 | 13 | static var shared = PermissionsService() 14 | 15 | @Published var cameraPermissionStatus: CameraPermissionStatus = .unknown 16 | @Published var photoLibraryPermissionStatus: PhotoLibraryPermissionStatus = .unknown 17 | 18 | /// photoLibraryChangePermissionPublisher gets called multiple times even when nothing changed in photo library, so just use this one to make sure the closure runs exactly once 19 | func requestPhotoLibraryPermission(_ permissionGrantedClosure: @Sendable @escaping ()->()) { 20 | Task { 21 | let currentStatus = PHPhotoLibrary.authorizationStatus(for: .addOnly) 22 | if currentStatus == .authorized || currentStatus == .limited { 23 | permissionGrantedClosure() 24 | return 25 | } 26 | 27 | let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly) 28 | updatePhotoLibraryAuthorizationStatus() 29 | if status == .authorized || status == .limited { 30 | permissionGrantedClosure() 31 | } 32 | } 33 | } 34 | 35 | func requestCameraPermission() { 36 | Task { 37 | await AVCaptureDevice.requestAccess(for: .video) 38 | updateCameraAuthorizationStatus() 39 | } 40 | } 41 | 42 | func updatePhotoLibraryAuthorizationStatus() { 43 | let status = PHPhotoLibrary.authorizationStatus(for: .addOnly) 44 | 45 | let result: PhotoLibraryPermissionStatus 46 | switch status { 47 | case .authorized: 48 | result = .authorized 49 | case .limited: 50 | result = .limited 51 | case .restricted, .denied: 52 | result = .unavailable 53 | case .notDetermined: 54 | result = .unknown 55 | default: 56 | result = .unknown 57 | } 58 | 59 | DispatchQueue.main.async { [weak self] in 60 | self?.photoLibraryPermissionStatus = result 61 | } 62 | } 63 | 64 | func updateCameraAuthorizationStatus() { 65 | let status = AVCaptureDevice.authorizationStatus(for: .video) 66 | 67 | let result: CameraPermissionStatus 68 | #if targetEnvironment(simulator) 69 | result = .unavailable 70 | #else 71 | switch status { 72 | case .authorized: 73 | result = .authorized 74 | case .restricted, .denied: 75 | result = .unavailable 76 | case .notDetermined: 77 | result = .unknown 78 | default: 79 | result = .unknown 80 | } 81 | #endif 82 | DispatchQueue.main.async { [weak self] in 83 | self?.cameraPermissionStatus = result 84 | } 85 | } 86 | } 87 | 88 | extension PermissionsService { 89 | enum CameraPermissionStatus { 90 | case authorized 91 | case unavailable 92 | case unknown 93 | } 94 | 95 | enum PhotoLibraryPermissionStatus { 96 | case limited 97 | case authorized 98 | case unavailable 99 | case unknown 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Managers/PhotoKit/PHAsset+Utils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 31.05.2022. 3 | // 4 | 5 | import Foundation 6 | import Photos 7 | import UniformTypeIdentifiers 8 | 9 | #if os(iOS) 10 | import UIKit.UIImage 11 | import UIKit.UIScreen 12 | #endif 13 | 14 | extension PHAsset { 15 | actor RequestStore { 16 | var request: Request? 17 | 18 | func storeRequest(_ request: Request) { 19 | self.request = request 20 | } 21 | 22 | func cancel(asset: PHAsset) { 23 | switch request { 24 | case .contentEditing(let id): 25 | asset.cancelContentEditingInputRequest(id) 26 | case .imageRequest(let id): 27 | PHCachingImageManager.default().cancelImageRequest(id) 28 | case .none: 29 | break 30 | } 31 | } 32 | } 33 | 34 | enum Request { 35 | case contentEditing(PHContentEditingInputRequestID) 36 | case imageRequest(PHImageRequestID) 37 | } 38 | 39 | func getURLCancellableRequest(completion: @escaping (URL?) -> Void) -> Request? { 40 | var request: Request? 41 | 42 | if mediaType == .image { 43 | let options = PHContentEditingInputRequestOptions() 44 | options.isNetworkAccessAllowed = true 45 | options.canHandleAdjustmentData = { _ -> Bool in 46 | return true 47 | } 48 | request = .contentEditing( 49 | requestContentEditingInput( 50 | with: options, 51 | completionHandler: { (contentEditingInput, _) in 52 | completion(contentEditingInput?.fullSizeImageURL) 53 | } 54 | ) 55 | ) 56 | } else if mediaType == .video { 57 | let options = PHVideoRequestOptions() 58 | options.version = .current 59 | options.isNetworkAccessAllowed = true 60 | options.deliveryMode = .highQualityFormat 61 | 62 | request = .imageRequest( 63 | PHCachingImageManager.default().requestAVAsset(forVideo: self, options: options) { avAsset, audio, info in 64 | let asset = avAsset as? AVURLAsset 65 | completion(asset?.url) 66 | } 67 | ) 68 | } 69 | 70 | return request 71 | } 72 | 73 | var formattedDuration: String? { 74 | guard mediaType == .video || mediaType == .audio else { 75 | return nil 76 | } 77 | return duration.formatted() 78 | } 79 | } 80 | 81 | extension PHAsset { 82 | func getURL() async -> URL? { 83 | let requestStore = RequestStore() 84 | 85 | return await withTaskCancellationHandler { 86 | await withCheckedContinuation { continuation in 87 | let request = getURLCancellableRequest { url in 88 | continuation.resume(returning: url) 89 | } 90 | if let request = request { 91 | Task { 92 | await requestStore.storeRequest(request) 93 | } 94 | } 95 | } 96 | } onCancel: { 97 | Task { 98 | await requestStore.cancel(asset: self) 99 | } 100 | } 101 | } 102 | 103 | func getThumbnailURL() async -> URL? { 104 | guard let url = await getURL() else { return nil } 105 | if mediaType == .image { 106 | return url 107 | } 108 | 109 | let asset: AVAsset = AVAsset(url: url) 110 | if let thumbnailData = asset.generateThumbnail() { 111 | return FileManager.storeToTempDir(data: thumbnailData) 112 | } 113 | 114 | return nil 115 | } 116 | 117 | func getThumbnailData() async -> Data? { 118 | if mediaType == .image { 119 | return try? await self.getData() 120 | } 121 | else if mediaType == .video { 122 | guard let url = await getURL() else { return nil } 123 | let asset: AVAsset = AVAsset(url: url) 124 | return asset.generateThumbnail() 125 | } 126 | return nil 127 | } 128 | } 129 | 130 | extension CGImage { 131 | var jpegData: Data? { 132 | guard let mutableData = CFDataCreateMutable(nil, 0), 133 | let destination = CGImageDestinationCreateWithData(mutableData, UTType.jpeg.identifier as CFString, 1, nil) else { return nil } 134 | CGImageDestinationAddImage(destination, self, nil) 135 | guard CGImageDestinationFinalize(destination) else { return nil } 136 | return mutableData as Data 137 | } 138 | } 139 | 140 | #if os(iOS) 141 | extension PHAsset { 142 | 143 | @MainActor 144 | func image(size: CGSize, resultClosure: @escaping (UIImage?)->()) -> PHImageRequestID { 145 | let requestSize = CGSize(width: size.width * UIScreen.main.scale, height: size.height * UIScreen.main.scale) 146 | 147 | let options = PHImageRequestOptions() 148 | options.isNetworkAccessAllowed = true 149 | options.deliveryMode = .opportunistic 150 | 151 | return PHCachingImageManager.default().requestImage( 152 | for: self, 153 | targetSize: requestSize, 154 | contentMode: .aspectFill, 155 | options: options, 156 | resultHandler: { image, info in 157 | resultClosure(image) // called for every quality approximation 158 | } 159 | ) 160 | } 161 | 162 | func getData() async throws -> Data? { 163 | try await withCheckedThrowingContinuation { continuation in 164 | if mediaType == .image { 165 | let options = PHImageRequestOptions() 166 | options.isNetworkAccessAllowed = true 167 | options.deliveryMode = .highQualityFormat 168 | options.isSynchronous = true 169 | 170 | PHCachingImageManager.default().requestImageDataAndOrientation( 171 | for: self, 172 | options: options, 173 | resultHandler: { data, _, _, info in 174 | guard info?.keys.contains(PHImageResultIsDegradedKey) == true 175 | else { fatalError("PHImageManager with `options.isSynchronous = true` should call result ONE time.") } 176 | if let data = data { 177 | continuation.resume(returning: data) 178 | } else { 179 | continuation.resume(throwing: AssetFetchError.noImageData) 180 | } 181 | } 182 | ) 183 | } 184 | else if mediaType == .video { 185 | let options = PHVideoRequestOptions() 186 | options.isNetworkAccessAllowed = true 187 | options.deliveryMode = .highQualityFormat 188 | options.version = .current 189 | 190 | PHCachingImageManager.default().requestAVAsset(forVideo: self, options: options) { avAsset, audio, info in 191 | do { 192 | if let asset = avAsset as? AVURLAsset { 193 | let data = try Data(contentsOf: asset.url) 194 | continuation.resume(returning: data) 195 | } 196 | } catch { 197 | continuation.resume(throwing: error) 198 | } 199 | } 200 | } else { 201 | continuation.resume(throwing: AssetFetchError.unknownType) 202 | } 203 | } 204 | } 205 | } 206 | 207 | extension AVAsset { 208 | func generateThumbnail() -> Data? { 209 | let imageGenerator = AVAssetImageGenerator(asset: self) 210 | imageGenerator.appliesPreferredTrackTransform = true 211 | do { 212 | let thumbnailImage = try imageGenerator.copyCGImage(at: CMTimeMake(value: 1, timescale: 60), actualTime: nil) 213 | guard let data = thumbnailImage.jpegData else { return nil } 214 | return data 215 | } catch let error { 216 | print(error) 217 | } 218 | return nil 219 | } 220 | } 221 | 222 | enum AssetFetchError: Error { 223 | case noImageData 224 | case unknownType 225 | } 226 | #endif 227 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Managers/PhotoKit/URL+Utils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by Alisa Mylnikova on 21.04.2023. 6 | // 7 | 8 | import SwiftUI 9 | import Photos 10 | 11 | extension URL { 12 | 13 | func getThumbnailURL() async -> URL? { 14 | let asset: AVAsset = AVAsset(url: self) 15 | if let thumbnailData = asset.generateThumbnail() { 16 | return FileManager.storeToTempDir(data: thumbnailData) 17 | } 18 | return nil 19 | } 20 | 21 | func getThumbnailData() async -> Data? { 22 | let asset: AVAsset = AVAsset(url: self) 23 | return asset.generateThumbnail() 24 | } 25 | 26 | var isImageFile: Bool { 27 | UTType(filenameExtension: pathExtension)?.conforms(to: .image) ?? false 28 | } 29 | 30 | var isVideoFile: Bool { 31 | UTType(filenameExtension: pathExtension)?.conforms(to: .audiovisualContent) ?? false 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Managers/Selection/CameraSelectionService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraSelectionService.swift 3 | // 4 | // 5 | // Created by Alisa Mylnikova on 12.07.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | final class CameraSelectionService: ObservableObject { 11 | 12 | var mediaSelectionLimit: Int? // if nill - unlimited 13 | var onChange: MediaPickerCompletionClosure? = nil 14 | 15 | @Published private(set) var added: [URLMediaModel] = [] 16 | @Published private(set) var selected: [URLMediaModel] = [] 17 | 18 | var hasSelected: Bool { 19 | !selected.isEmpty 20 | } 21 | 22 | var fitsSelectionLimit: Bool { 23 | if let selectionLimit = mediaSelectionLimit { 24 | return selected.count < selectionLimit 25 | } 26 | return true 27 | } 28 | 29 | func canSelect(media: URLMediaModel) -> Bool { 30 | fitsSelectionLimit || selected.contains(media) 31 | } 32 | 33 | func onSelect(media: URLMediaModel) { 34 | if added.contains(media) { 35 | if let index = selected.firstIndex(of: media) { 36 | selected.remove(at: index) 37 | } else if fitsSelectionLimit { 38 | selected.append(media) 39 | } 40 | } else { 41 | added.append(media) 42 | if fitsSelectionLimit { 43 | selected.append(media) 44 | } 45 | } 46 | onChange?(mapToMedia()) 47 | } 48 | 49 | func onSelect(index: Int) { 50 | guard added.indices.contains(index) else { return } 51 | let media = added[index] 52 | if let index = selected.firstIndex(of: media) { 53 | selected.remove(at: index) 54 | } else if fitsSelectionLimit { 55 | selected.append(media) 56 | } 57 | onChange?(mapToMedia()) 58 | } 59 | 60 | func isSelected(index: Int) -> Bool { 61 | guard added.indices.contains(index) else { return false } 62 | return selected.contains(added[index]) 63 | } 64 | 65 | func selectedIndex(fromAddedIndex index: Int) -> Int? { 66 | guard added.indices.contains(index) else { return nil } 67 | let media = added[index] 68 | return selected.firstIndex(of: media) 69 | } 70 | 71 | func mapToMedia() -> [Media] { 72 | selected 73 | .compactMap { 74 | guard $0.mediaType != nil else { 75 | return nil 76 | } 77 | return Media(source: $0) 78 | } 79 | } 80 | 81 | func removeAll() { 82 | selected.removeAll() 83 | added.removeAll() 84 | onChange?([]) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Managers/Selection/SelectionParamsHolder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SelectionParamsHolder.swift 3 | // 4 | // 5 | // Created by Alisa Mylnikova on 05.05.2023. 6 | // 7 | 8 | import SwiftUI 9 | 10 | final public class SelectionParamsHolder: ObservableObject { 11 | 12 | @Published public var mediaType: MediaSelectionType = .photoAndVideo 13 | @Published public var selectionStyle: MediaSelectionStyle = .checkmark 14 | @Published public var selectionLimit: Int? // if nil - unlimited 15 | @Published public var showFullscreenPreview: Bool = true // if false, tap on image immediately selects this image and closes the picker 16 | 17 | public init(mediaType: MediaSelectionType = .photoAndVideo, selectionStyle: MediaSelectionStyle = .checkmark, selectionLimit: Int? = nil, showFullscreenPreview: Bool = true) { 18 | self.mediaType = mediaType 19 | self.selectionStyle = selectionStyle 20 | self.selectionLimit = selectionLimit 21 | self.showFullscreenPreview = showFullscreenPreview 22 | } 23 | } 24 | 25 | public enum MediaSelectionStyle { 26 | case checkmark 27 | case count 28 | } 29 | 30 | public enum MediaSelectionType { 31 | case photoAndVideo 32 | case photo 33 | case video 34 | 35 | var allowsPhoto: Bool { 36 | [.photoAndVideo, .photo].contains(self) 37 | } 38 | 39 | var allowsVideo: Bool { 40 | [.photoAndVideo, .video].contains(self) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Managers/Selection/SelectionService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 08.06.2022. 3 | // 4 | 5 | import Foundation 6 | import SwiftUI 7 | import Photos 8 | 9 | final class SelectionService: ObservableObject { 10 | 11 | var mediaSelectionLimit: Int? 12 | var onChange: MediaPickerCompletionClosure? = nil 13 | 14 | @Published private(set) var selected: [AssetMediaModel] = [] 15 | 16 | var canSendSelected: Bool { 17 | !selected.isEmpty 18 | } 19 | 20 | var fitsSelectionLimit: Bool { 21 | if let selectionLimit = mediaSelectionLimit { 22 | return selected.count < selectionLimit 23 | } 24 | return true 25 | } 26 | 27 | func canSelect(assetMediaModel: AssetMediaModel) -> Bool { 28 | fitsSelectionLimit || selected.contains(assetMediaModel) 29 | } 30 | 31 | func onSelect(assetMediaModel: AssetMediaModel) { 32 | if let index = selected.firstIndex(of: assetMediaModel) { 33 | selected.remove(at: index) 34 | } else { 35 | if fitsSelectionLimit { 36 | selected.append(assetMediaModel) 37 | } 38 | } 39 | onChange?(mapToMedia()) 40 | } 41 | 42 | func index(of assetMediaModel: AssetMediaModel) -> Int? { 43 | selected.firstIndex(of: assetMediaModel) 44 | } 45 | 46 | func mapToMedia() -> [Media] { 47 | selected 48 | .compactMap { 49 | guard $0.mediaType != nil else { 50 | return nil 51 | } 52 | return Media(source: $0) 53 | } 54 | } 55 | 56 | func removeAll() { 57 | selected.removeAll() 58 | onChange?([]) 59 | } 60 | 61 | func updateSelection(with models: [AssetMediaModel]) { 62 | selected = selected.filter { 63 | models.contains($0) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Model/AlbumModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 27.05.2022. 3 | // 4 | 5 | import Foundation 6 | import Photos 7 | 8 | public struct Album: Identifiable { 9 | public let id: String 10 | public let title: String? 11 | public let preview: PHAsset? 12 | } 13 | 14 | struct AlbumModel { 15 | let preview: AssetMediaModel? 16 | let source: PHAssetCollection 17 | } 18 | 19 | extension AlbumModel: Identifiable { 20 | public var id: String { 21 | source.localIdentifier 22 | } 23 | 24 | public var title: String? { 25 | source.localizedTitle 26 | } 27 | } 28 | 29 | extension AlbumModel: Equatable {} 30 | 31 | extension AlbumModel { 32 | func toAlbum() -> Album { 33 | Album(id: id, title: title, preview: preview?.asset) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Model/AssetMediaModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 27.05.2022. 3 | // 4 | 5 | import Foundation 6 | import Photos 7 | 8 | struct AssetMediaModel { 9 | let asset: PHAsset 10 | } 11 | 12 | extension AssetMediaModel: MediaModelProtocol { 13 | 14 | var mediaType: MediaType? { 15 | switch asset.mediaType { 16 | case .image: 17 | return .image 18 | case .video: 19 | return .video 20 | default: 21 | return nil 22 | } 23 | } 24 | 25 | var duration: CGFloat? { 26 | CGFloat(asset.duration) 27 | } 28 | 29 | func getURL() async -> URL? { 30 | await asset.getURL() 31 | } 32 | 33 | func getThumbnailURL() async -> URL? { 34 | await asset.getThumbnailURL() 35 | } 36 | 37 | func getData() async throws -> Data? { 38 | try await asset.getData() 39 | } 40 | 41 | func getThumbnailData() async -> Data? { 42 | await asset.getThumbnailData() 43 | } 44 | } 45 | 46 | extension AssetMediaModel: Identifiable { 47 | var id: String { 48 | asset.localIdentifier 49 | } 50 | } 51 | 52 | extension AssetMediaModel: Equatable { 53 | static func ==(lhs: AssetMediaModel, rhs: AssetMediaModel) -> Bool { 54 | lhs.id == rhs.id 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Model/Media.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 31.05.2022. 3 | // 4 | 5 | import Foundation 6 | 7 | public enum MediaType { 8 | case image 9 | case video 10 | } 11 | 12 | public struct Media: Identifiable, Equatable, Sendable { 13 | public var id = UUID() 14 | internal let source: MediaModelProtocol 15 | 16 | public static func == (lhs: Media, rhs: Media) -> Bool { 17 | lhs.id == rhs.id 18 | } 19 | } 20 | 21 | public extension Media { 22 | 23 | var type: MediaType { 24 | source.mediaType ?? .image 25 | } 26 | 27 | var duration: CGFloat? { 28 | get async { 29 | await source.duration 30 | } 31 | } 32 | 33 | func getURL() async -> URL? { 34 | await source.getURL() 35 | } 36 | 37 | func getThumbnailURL() async -> URL? { 38 | await source.getThumbnailURL() 39 | } 40 | 41 | func getData() async -> Data? { 42 | try? await source.getData() 43 | } 44 | 45 | func getThumbnailData() async -> Data? { 46 | await source.getThumbnailData() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Model/MediaModelProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by Alisa Mylnikova on 21.04.2023. 6 | // 7 | 8 | import SwiftUI 9 | 10 | protocol MediaModelProtocol: Sendable { 11 | var mediaType: MediaType? { get } 12 | var duration: CGFloat? { get async } 13 | 14 | func getURL() async -> URL? 15 | func getThumbnailURL() async -> URL? 16 | 17 | func getData() async throws -> Data? 18 | func getThumbnailData() async -> Data? 19 | } 20 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Model/Types.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 04.07.2022. 3 | // 4 | 5 | import Foundation 6 | 7 | public typealias MediaPickerCompletionClosure = ([Media]) -> Void 8 | public typealias MediaPickerOrientationHandler = (ShouldLock) -> Void 9 | public typealias SimpleClosure = ()->() 10 | 11 | public enum ShouldLock { 12 | case lock, unlock 13 | } 14 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Model/URLMediaModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by Alisa Mylnikova on 12.07.2022. 6 | // 7 | 8 | import SwiftUI 9 | import UniformTypeIdentifiers 10 | import AVFoundation 11 | 12 | struct URLMediaModel { 13 | let url: URL 14 | } 15 | 16 | extension URLMediaModel: MediaModelProtocol { 17 | 18 | var mediaType: MediaType? { 19 | if url.isImageFile { 20 | return .image 21 | } 22 | if url.isVideoFile { 23 | return .video 24 | } 25 | return nil 26 | } 27 | 28 | var duration: CGFloat? { 29 | get async { 30 | let asset = AVURLAsset(url: url) 31 | do { 32 | let duration = try await asset.load(.duration) 33 | return CGFloat(CMTimeGetSeconds(duration)) 34 | } catch { 35 | return nil 36 | } 37 | } 38 | } 39 | 40 | func getURL() async -> URL? { 41 | url 42 | } 43 | 44 | func getThumbnailURL() async -> URL? { 45 | switch mediaType { 46 | case .image: 47 | return url 48 | case .video: 49 | return await url.getThumbnailURL() 50 | case .none: 51 | return nil 52 | } 53 | } 54 | 55 | func getData() async throws -> Data? { 56 | try? Data(contentsOf: url) 57 | } 58 | 59 | func getThumbnailData() async -> Data? { 60 | switch mediaType { 61 | case .image: 62 | return try? Data(contentsOf: url) 63 | case .video: 64 | return await url.getThumbnailData() 65 | case .none: 66 | return nil 67 | } 68 | } 69 | } 70 | 71 | extension URLMediaModel: Identifiable { 72 | var id: String { 73 | url.absoluteString 74 | } 75 | } 76 | 77 | extension URLMediaModel: Equatable { 78 | static func ==(lhs: URLMediaModel, rhs: URLMediaModel) -> Bool { 79 | lhs.id == rhs.id 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/cameraBG.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x00", 9 | "green" : "0x00", 10 | "red" : "0x00" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x00", 27 | "green" : "0x00", 28 | "red" : "0x00" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/cameraText.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xFF", 9 | "green" : "0xFF", 10 | "red" : "0xFE" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xFF", 27 | "green" : "0xFF", 28 | "red" : "0xFE" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/pickerBG.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xFF", 9 | "green" : "0xFF", 10 | "red" : "0xFF" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x00", 27 | "green" : "0x00", 28 | "red" : "0x00" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/pickerText.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x00", 9 | "green" : "0x00", 10 | "red" : "0x00" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xFF", 27 | "green" : "0xFF", 28 | "red" : "0xFE" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/selection.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xFF", 9 | "green" : "0x71", 10 | "red" : "0x5A" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xFF", 27 | "green" : "0x71", 28 | "red" : "0x5A" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Resources/Media.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/FlashOff.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Flash.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/FlashOff.imageset/Flash.pdf: -------------------------------------------------------------------------------- 1 | %PDF-1.7 2 | 3 | 1 0 obj 4 | << /ExtGState << /E1 << /ca 0.120000 >> >> >> 5 | endobj 6 | 7 | 2 0 obj 8 | << /Length 3 0 R >> 9 | stream 10 | /DeviceRGB CS 11 | /DeviceRGB cs 12 | q 13 | /E1 gs 14 | 1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm 15 | 0.949020 0.952941 0.960784 scn 16 | 20.000002 -0.000004 m 17 | 31.045696 -0.000004 40.000004 8.954304 40.000004 19.999998 c 18 | 40.000004 31.045694 31.045696 40.000000 20.000002 40.000000 c 19 | 8.954306 40.000000 0.000000 31.045694 0.000000 19.999998 c 20 | 0.000000 8.954304 8.954306 -0.000004 20.000002 -0.000004 c 21 | h 22 | f* 23 | n 24 | Q 25 | q 26 | 1.000000 0.000000 -0.000000 1.000000 11.000000 11.457779 cm 27 | 1.000000 1.000000 1.000000 scn 28 | 9.811744 3.343153 m 29 | 12.219169 0.333872 l 30 | 12.564178 -0.097391 13.193470 -0.167313 13.624732 0.177698 c 31 | 14.055994 0.522707 14.125916 1.151999 13.780906 1.583260 c 32 | 1.780906 16.583261 l 33 | 1.435896 17.014523 0.806604 17.084444 0.375342 16.739435 c 34 | -0.055920 16.394424 -0.125842 15.765133 0.219168 15.333871 c 35 | 4.307005 10.224075 l 36 | 4.090437 7.625266 l 37 | 4.041852 7.042247 4.501943 6.542221 5.086983 6.542221 c 38 | 7.252489 6.542221 l 39 | 7.946003 5.675328 l 40 | 7.500184 -1.457779 l 41 | 9.811744 3.343153 l 42 | h 43 | f 44 | n 45 | Q 46 | q 47 | 1.000000 0.000000 -0.000000 1.000000 11.000000 18.797745 cm 48 | 1.000000 1.000000 1.000000 scn 49 | 13.724041 4.128729 m 50 | 11.736132 -0.000007 l 51 | 4.790193 8.682361 l 52 | 5.000184 11.202255 l 53 | 10.958600 11.202255 l 54 | 11.227722 11.202255 11.420056 10.941842 11.340912 10.684621 c 55 | 9.659456 5.219890 l 56 | 9.580311 4.962668 9.772646 4.702255 10.041767 4.702255 c 57 | 13.363640 4.702255 l 58 | 13.658390 4.702255 13.851908 4.394299 13.724041 4.128729 c 59 | h 60 | f 61 | n 62 | Q 63 | 64 | endstream 65 | endobj 66 | 67 | 3 0 obj 68 | 1405 69 | endobj 70 | 71 | 4 0 obj 72 | << /Annots [] 73 | /Type /Page 74 | /MediaBox [ 0.000000 0.000000 40.000000 40.000000 ] 75 | /Resources 1 0 R 76 | /Contents 2 0 R 77 | /Parent 5 0 R 78 | >> 79 | endobj 80 | 81 | 5 0 obj 82 | << /Kids [ 4 0 R ] 83 | /Count 1 84 | /Type /Pages 85 | >> 86 | endobj 87 | 88 | 6 0 obj 89 | << /Pages 5 0 R 90 | /Type /Catalog 91 | >> 92 | endobj 93 | 94 | xref 95 | 0 7 96 | 0000000000 65535 f 97 | 0000000010 00000 n 98 | 0000000074 00000 n 99 | 0000001535 00000 n 100 | 0000001558 00000 n 101 | 0000001731 00000 n 102 | 0000001805 00000 n 103 | trailer 104 | << /ID [ (some) (id) ] 105 | /Root 6 0 R 106 | /Size 7 107 | >> 108 | startxref 109 | 1864 110 | %%EOF -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/FlashOn.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Flash on.pdf", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/FlashOn.imageset/Flash on.pdf: -------------------------------------------------------------------------------- 1 | %PDF-1.7 2 | 3 | 1 0 obj 4 | << /ExtGState << /E1 << /ca 0.120000 >> >> >> 5 | endobj 6 | 7 | 2 0 obj 8 | << /Length 3 0 R >> 9 | stream 10 | /DeviceRGB CS 11 | /DeviceRGB cs 12 | q 13 | /E1 gs 14 | 1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm 15 | 0.949020 0.952941 0.960784 scn 16 | 20.000002 -0.000004 m 17 | 31.045696 -0.000004 40.000004 8.954304 40.000004 19.999998 c 18 | 40.000004 31.045694 31.045696 40.000000 20.000002 40.000000 c 19 | 8.954306 40.000000 0.000000 31.045694 0.000000 19.999998 c 20 | 0.000000 8.954304 8.954306 -0.000004 20.000002 -0.000004 c 21 | h 22 | f* 23 | n 24 | Q 25 | q 26 | 1.000000 0.000000 -0.000000 1.000000 15.086914 10.000000 cm 27 | 1.000000 1.000000 1.000000 scn 28 | 6.871686 20.000000 m 29 | 0.913270 20.000000 l 30 | 0.003523 9.083045 l 31 | -0.045062 8.500026 0.415029 8.000000 1.000069 8.000000 c 32 | 3.487489 8.000000 l 33 | 3.718218 8.000000 3.901103 7.805328 3.886710 7.575049 c 34 | 3.413270 0.000000 l 35 | 9.637128 12.926474 l 36 | 9.764995 13.192043 9.571477 13.500000 9.276727 13.500000 c 37 | 5.954853 13.500000 l 38 | 5.685731 13.500000 5.493397 13.760413 5.572542 14.017634 c 39 | 7.253997 19.482367 l 40 | 7.333142 19.739586 7.140808 20.000000 6.871686 20.000000 c 41 | h 42 | f 43 | n 44 | Q 45 | 46 | endstream 47 | endobj 48 | 49 | 3 0 obj 50 | 954 51 | endobj 52 | 53 | 4 0 obj 54 | << /Annots [] 55 | /Type /Page 56 | /MediaBox [ 0.000000 0.000000 40.000000 40.000000 ] 57 | /Resources 1 0 R 58 | /Contents 2 0 R 59 | /Parent 5 0 R 60 | >> 61 | endobj 62 | 63 | 5 0 obj 64 | << /Kids [ 4 0 R ] 65 | /Count 1 66 | /Type /Pages 67 | >> 68 | endobj 69 | 70 | 6 0 obj 71 | << /Pages 5 0 R 72 | /Type /Catalog 73 | >> 74 | endobj 75 | 76 | xref 77 | 0 7 78 | 0000000000 65535 f 79 | 0000000010 00000 n 80 | 0000000074 00000 n 81 | 0000001084 00000 n 82 | 0000001106 00000 n 83 | 0000001279 00000 n 84 | 0000001353 00000 n 85 | trailer 86 | << /ID [ (some) (id) ] 87 | /Root 6 0 R 88 | /Size 7 89 | >> 90 | startxref 91 | 1412 92 | %%EOF -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/FlipCamera.imageset/Change Camera.pdf: -------------------------------------------------------------------------------- 1 | %PDF-1.7 2 | 3 | 1 0 obj 4 | << /ExtGState << /E1 << /ca 0.120000 >> >> >> 5 | endobj 6 | 7 | 2 0 obj 8 | << /Length 3 0 R >> 9 | stream 10 | /DeviceRGB CS 11 | /DeviceRGB cs 12 | q 13 | /E1 gs 14 | 1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm 15 | 0.949020 0.952941 0.960784 scn 16 | 20.000002 -0.000004 m 17 | 31.045696 -0.000004 40.000004 8.954304 40.000004 19.999998 c 18 | 40.000004 31.045694 31.045696 40.000000 20.000002 40.000000 c 19 | 8.954306 40.000000 0.000000 31.045694 0.000000 19.999998 c 20 | 0.000000 8.954304 8.954306 -0.000004 20.000002 -0.000004 c 21 | h 22 | f* 23 | n 24 | Q 25 | q 26 | 1.000000 0.000000 -0.000000 1.000000 16.500000 15.000000 cm 27 | 1.000000 1.000000 1.000000 scn 28 | 6.250000 5.000000 m 29 | 6.250000 3.481217 5.018783 2.250000 3.500000 2.250000 c 30 | 3.500000 0.750000 l 31 | 5.847210 0.750000 7.750000 2.652790 7.750000 5.000000 c 32 | 6.250000 5.000000 l 33 | h 34 | 3.500000 2.250000 m 35 | 1.981217 2.250000 0.750000 3.481217 0.750000 5.000000 c 36 | -0.750000 5.000000 l 37 | -0.750000 2.652790 1.152790 0.750000 3.500000 0.750000 c 38 | 3.500000 2.250000 l 39 | h 40 | 0.750000 5.000000 m 41 | 0.750000 6.518783 1.981217 7.750000 3.500000 7.750000 c 42 | 3.500000 9.250000 l 43 | 1.152790 9.250000 -0.750000 7.347210 -0.750000 5.000000 c 44 | 0.750000 5.000000 l 45 | h 46 | 3.500000 7.750000 m 47 | 5.018783 7.750000 6.250000 6.518783 6.250000 5.000000 c 48 | 7.750000 5.000000 l 49 | 7.750000 7.347210 5.847210 9.250000 3.500000 9.250000 c 50 | 3.500000 7.750000 l 51 | h 52 | f 53 | n 54 | Q 55 | q 56 | 1.000000 0.000000 -0.000000 1.000000 10.000000 14.426773 cm 57 | 1.000000 1.000000 1.000000 scn 58 | -0.750000 1.573227 m 59 | -0.750000 1.159014 -0.414214 0.823227 0.000000 0.823227 c 60 | 0.414214 0.823227 0.750000 1.159014 0.750000 1.573227 c 61 | -0.750000 1.573227 l 62 | h 63 | 16.000000 13.573227 m 64 | 16.530331 13.042896 l 65 | 16.823223 13.335791 16.823223 13.810664 16.530331 14.103557 c 66 | 16.000000 13.573227 l 67 | h 68 | 14.530330 16.103558 m 69 | 14.237437 16.396450 13.762563 16.396450 13.469670 16.103558 c 70 | 13.176777 15.810664 13.176777 15.335790 13.469670 15.042897 c 71 | 14.530330 16.103558 l 72 | h 73 | 13.469670 12.103558 m 74 | 13.176777 11.810663 13.176777 11.335791 13.469670 11.042896 c 75 | 13.762563 10.750004 14.237437 10.750004 14.530330 11.042896 c 76 | 13.469670 12.103558 l 77 | h 78 | 0.750000 1.573227 m 79 | 0.750000 9.573227 l 80 | -0.750000 9.573227 l 81 | -0.750000 1.573227 l 82 | 0.750000 1.573227 l 83 | h 84 | 4.000000 12.823227 m 85 | 16.000000 12.823227 l 86 | 16.000000 14.323227 l 87 | 4.000000 14.323227 l 88 | 4.000000 12.823227 l 89 | h 90 | 16.530331 14.103557 m 91 | 14.530330 16.103558 l 92 | 13.469670 15.042897 l 93 | 15.469670 13.042896 l 94 | 16.530331 14.103557 l 95 | h 96 | 15.469670 14.103557 m 97 | 13.469670 12.103558 l 98 | 14.530330 11.042896 l 99 | 16.530331 13.042896 l 100 | 15.469670 14.103557 l 101 | h 102 | 0.750000 9.573227 m 103 | 0.750000 11.368153 2.205075 12.823227 4.000000 12.823227 c 104 | 4.000000 14.323227 l 105 | 1.376648 14.323227 -0.750000 12.196580 -0.750000 9.573227 c 106 | 0.750000 9.573227 l 107 | h 108 | f 109 | n 110 | Q 111 | q 112 | -1.000000 -0.000000 -0.000000 -1.000000 30.000000 25.573227 cm 113 | 1.000000 1.000000 1.000000 scn 114 | -0.750000 1.573227 m 115 | -0.750000 1.159014 -0.414214 0.823227 0.000000 0.823227 c 116 | 0.414214 0.823227 0.750000 1.159014 0.750000 1.573227 c 117 | -0.750000 1.573227 l 118 | h 119 | 16.000000 13.573227 m 120 | 16.530331 13.042896 l 121 | 16.823223 13.335791 16.823223 13.810664 16.530331 14.103557 c 122 | 16.000000 13.573227 l 123 | h 124 | 14.530330 16.103558 m 125 | 14.237437 16.396450 13.762563 16.396450 13.469670 16.103558 c 126 | 13.176777 15.810664 13.176777 15.335790 13.469670 15.042897 c 127 | 14.530330 16.103558 l 128 | h 129 | 13.469670 12.103558 m 130 | 13.176777 11.810663 13.176777 11.335791 13.469670 11.042896 c 131 | 13.762563 10.750004 14.237437 10.750004 14.530330 11.042896 c 132 | 13.469670 12.103558 l 133 | h 134 | 0.750000 1.573227 m 135 | 0.750000 9.573227 l 136 | -0.750000 9.573227 l 137 | -0.750000 1.573227 l 138 | 0.750000 1.573227 l 139 | h 140 | 4.000000 12.823227 m 141 | 16.000000 12.823227 l 142 | 16.000000 14.323227 l 143 | 4.000000 14.323227 l 144 | 4.000000 12.823227 l 145 | h 146 | 16.530331 14.103557 m 147 | 14.530330 16.103558 l 148 | 13.469670 15.042897 l 149 | 15.469670 13.042896 l 150 | 16.530331 14.103557 l 151 | h 152 | 15.469670 14.103557 m 153 | 13.469670 12.103558 l 154 | 14.530330 11.042896 l 155 | 16.530331 13.042896 l 156 | 15.469670 14.103557 l 157 | h 158 | 0.750000 9.573227 m 159 | 0.750000 11.368153 2.205075 12.823227 4.000000 12.823227 c 160 | 4.000000 14.323227 l 161 | 1.376648 14.323227 -0.750000 12.196580 -0.750000 9.573227 c 162 | 0.750000 9.573227 l 163 | h 164 | f 165 | n 166 | Q 167 | 168 | endstream 169 | endobj 170 | 171 | 3 0 obj 172 | 3892 173 | endobj 174 | 175 | 4 0 obj 176 | << /Annots [] 177 | /Type /Page 178 | /MediaBox [ 0.000000 0.000000 40.000000 40.000000 ] 179 | /Resources 1 0 R 180 | /Contents 2 0 R 181 | /Parent 5 0 R 182 | >> 183 | endobj 184 | 185 | 5 0 obj 186 | << /Kids [ 4 0 R ] 187 | /Count 1 188 | /Type /Pages 189 | >> 190 | endobj 191 | 192 | 6 0 obj 193 | << /Pages 5 0 R 194 | /Type /Catalog 195 | >> 196 | endobj 197 | 198 | xref 199 | 0 7 200 | 0000000000 65535 f 201 | 0000000010 00000 n 202 | 0000000074 00000 n 203 | 0000004022 00000 n 204 | 0000004045 00000 n 205 | 0000004218 00000 n 206 | 0000004292 00000 n 207 | trailer 208 | << /ID [ (some) (id) ] 209 | /Root 6 0 R 210 | /Size 7 211 | >> 212 | startxref 213 | 4351 214 | %%EOF -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/FlipCamera.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Change Camera.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/AlbumView/AlbumView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 27.05.2022. 3 | // 4 | 5 | import SwiftUI 6 | import AnchoredPopup 7 | 8 | struct AlbumView: View { 9 | 10 | @EnvironmentObject private var selectionService: SelectionService 11 | @Environment(\.mediaPickerTheme) private var theme 12 | 13 | @ObservedObject var keyboardHeightHelper = KeyboardHeightHelper.shared 14 | @ObservedObject var permissionsService = PermissionsService.shared 15 | 16 | @StateObject var viewModel: BaseMediasProvider 17 | @Binding var showingCamera: Bool 18 | @Binding var currentFullscreenMedia: Media? 19 | 20 | var shouldShowCamera: Bool 21 | var selectionParamsHolder: SelectionParamsHolder 22 | var dismiss: ()->() 23 | 24 | @State private var fullscreenItem: AssetMediaModel.ID? 25 | 26 | private var shouldShowLoadingCell: Bool { 27 | viewModel.isLoading && viewModel.assetMediaModels.count > 0 28 | } 29 | 30 | var body: some View { 31 | content 32 | .onAppear { 33 | viewModel.reload() 34 | } 35 | .onDisappear { 36 | viewModel.cancel() 37 | } 38 | } 39 | 40 | @ViewBuilder 41 | var content: some View { 42 | ScrollView { 43 | VStack(spacing: 0) { 44 | PermissionActionView(type: .library(permissionsService.photoLibraryPermissionStatus)) 45 | 46 | if shouldShowCamera { 47 | PermissionActionView(type: .camera(permissionsService.cameraPermissionStatus)) 48 | } 49 | 50 | if viewModel.isLoading, viewModel.assetMediaModels.isEmpty { 51 | ProgressView() 52 | .padding() 53 | } else if !viewModel.isLoading, viewModel.assetMediaModels.isEmpty { 54 | Text("Empty data") 55 | .font(.title3) 56 | .foregroundColor(theme.main.pickerText) 57 | } else { 58 | mediasGrid 59 | } 60 | 61 | Spacer() 62 | } 63 | .frame(maxWidth: .infinity) 64 | } 65 | .background(theme.main.pickerBackground) 66 | .onTapGesture { 67 | if keyboardHeightHelper.keyboardDisplayed { 68 | dismissKeyboard() 69 | } 70 | } 71 | } 72 | 73 | var mediasGrid: some View { 74 | MediasGrid(viewModel.assetMediaModels) { 75 | #if !targetEnvironment(simulator) 76 | if shouldShowCamera && permissionsService.cameraPermissionStatus == .authorized { 77 | LiveCameraCell { 78 | showingCamera = true 79 | } 80 | } 81 | #endif 82 | } content: { assetMediaModel, index, cellSize in 83 | cellView(assetMediaModel, index, cellSize) 84 | } loadingCell: { 85 | if shouldShowLoadingCell { 86 | ZStack { 87 | Color.white.opacity(0.5) 88 | ProgressView() 89 | } 90 | .aspectRatio(1, contentMode: .fit) 91 | } 92 | } 93 | .onChange(of: viewModel.assetMediaModels) { _ , newValue in 94 | selectionService.updateSelection(with: newValue) 95 | } 96 | } 97 | 98 | @ViewBuilder 99 | func cellView(_ assetMediaModel: AssetMediaModel, _ index: Int, _ size: CGFloat) -> some View { 100 | let imageButton = Button { 101 | if keyboardHeightHelper.keyboardDisplayed { 102 | dismissKeyboard() 103 | } 104 | if !selectionParamsHolder.showFullscreenPreview { // select immediately 105 | selectionService.onSelect(assetMediaModel: assetMediaModel) 106 | if selectionService.mediaSelectionLimit == 1 { 107 | dismiss() 108 | } 109 | } 110 | else if fullscreenItem == nil { 111 | fullscreenItem = assetMediaModel.id 112 | } 113 | } label: { 114 | let id = "fullscreen_photo_\(index)" 115 | MediaCell(viewModel: MediaViewModel(assetMediaModel: assetMediaModel), size: size) 116 | .applyIf(selectionParamsHolder.showFullscreenPreview) { 117 | $0.useAsPopupAnchor(id: id) { 118 | FullscreenContainer( 119 | currentFullscreenMedia: $currentFullscreenMedia, 120 | selection: $fullscreenItem, 121 | animationID: id, 122 | assetMediaModels: viewModel.assetMediaModels, 123 | selectionParamsHolder: selectionParamsHolder, 124 | dismiss: dismiss 125 | ) 126 | .environmentObject(selectionService) 127 | } customize: { 128 | $0.closeOnTap(false) 129 | .animation(.easeIn(duration: 0.2)) 130 | } 131 | .simultaneousGesture( 132 | TapGesture().onEnded { 133 | fullscreenItem = assetMediaModel.id 134 | } 135 | ) 136 | } 137 | } 138 | .buttonStyle(MediaButtonStyle()) 139 | .contentShape(Rectangle()) 140 | 141 | if selectionService.mediaSelectionLimit == 1 { 142 | imageButton 143 | } else { 144 | SelectableView(selected: selectionService.index(of: assetMediaModel), isFullscreen: false, canSelect: selectionService.canSelect(assetMediaModel: assetMediaModel), selectionParamsHolder: selectionParamsHolder) { 145 | selectionService.onSelect(assetMediaModel: assetMediaModel) 146 | } content: { 147 | imageButton 148 | } 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/AlbumView/MediaCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 27.05.2022. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct MediaCell: View { 8 | 9 | @Environment(\.mediaPickerTheme) private var theme 10 | 11 | @StateObject var viewModel: MediaViewModel 12 | var size: CGFloat 13 | 14 | var body: some View { 15 | ZStack { 16 | ThumbnailView(preview: viewModel.preview, size: size) 17 | .cornerRadius(theme.cellStyle.cornerRadius) 18 | .onAppear { 19 | viewModel.onStart(size: size) 20 | } 21 | .aspectRatio(1, contentMode: .fill) 22 | .clipped() 23 | 24 | if let duration = viewModel.assetMediaModel.asset.formattedDuration { 25 | VStack { 26 | Spacer() 27 | Rectangle() 28 | .fill(LinearGradient(colors: [.black, .clear], startPoint: .bottom, endPoint: .top)) 29 | } 30 | VStack { 31 | Spacer() 32 | HStack { 33 | Spacer() 34 | Text(duration) 35 | .font(.subheadline) 36 | .foregroundColor(.white) 37 | .padding(.trailing, 4) 38 | .padding(.bottom, 4) 39 | } 40 | } 41 | } 42 | } 43 | .onDisappear { 44 | viewModel.onStop() 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/AlbumView/MediaViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 03.06.2022. 3 | // 4 | 5 | #if os(iOS) 6 | import UIKit.UIImage 7 | #endif 8 | import Photos 9 | 10 | class MediaViewModel: ObservableObject { 11 | let assetMediaModel: AssetMediaModel 12 | 13 | private var requestID: PHImageRequestID? 14 | 15 | init(assetMediaModel: AssetMediaModel) { 16 | self.assetMediaModel = assetMediaModel 17 | } 18 | 19 | #if os(iOS) 20 | @Published var preview: UIImage? = nil 21 | #else 22 | // FIXME: Create preview for image/video for other platforms 23 | #endif 24 | 25 | @MainActor func onStart(size: CGFloat) { 26 | requestID = assetMediaModel.asset 27 | .image(size: CGSize(width: size, height: size)) { image in 28 | DispatchQueue.main.async { 29 | self.preview = image 30 | } 31 | } 32 | } 33 | 34 | func onStop() { 35 | if let requestID = requestID { 36 | PHCachingImageManager.default().cancelImageRequest(requestID) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/AlbumsView/AlbumCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 30.05.2022. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct AlbumCell: View { 8 | 9 | @Environment(\.mediaPickerTheme) private var theme 10 | 11 | @StateObject var viewModel: AlbumCellViewModel 12 | var size: CGFloat 13 | 14 | var body: some View { 15 | VStack { 16 | Rectangle() 17 | .aspectRatio(1, contentMode: .fit) 18 | .overlay { 19 | ThumbnailView(preview: viewModel.preview, size: size) 20 | .onAppear { 21 | viewModel.fetchPreview(size: CGSize(width: size, height: size)) 22 | } 23 | } 24 | .clipped() 25 | .foregroundColor(theme.main.pickerBackground) 26 | 27 | if let title = viewModel.album.title { 28 | Text(title) 29 | .lineLimit(2) 30 | .multilineTextAlignment(.center) 31 | .foregroundColor(theme.main.pickerText) 32 | } 33 | } 34 | .onDisappear { 35 | viewModel.onStop() 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/AlbumsView/AlbumCellViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 03.06.2022. 3 | // 4 | 5 | #if os(iOS) 6 | import UIKit.UIImage 7 | #endif 8 | import Photos 9 | 10 | class AlbumCellViewModel: ObservableObject { 11 | let album: AlbumModel 12 | 13 | private var requestID: PHImageRequestID? 14 | 15 | init(album: AlbumModel) { 16 | self.album = album 17 | } 18 | 19 | #if os(iOS) 20 | @Published var preview: UIImage? = nil 21 | #else 22 | // FIXME: Create preview for image/video for other platforms 23 | #endif 24 | 25 | @MainActor func fetchPreview(size: CGSize) { 26 | guard preview == nil else { return } 27 | 28 | requestID = album.preview?.asset 29 | .image(size: size) { [weak self] image in 30 | DispatchQueue.main.async { 31 | self?.preview = image 32 | } 33 | } 34 | } 35 | 36 | func onStop() { 37 | if let requestID = requestID { 38 | PHCachingImageManager.default().cancelImageRequest(requestID) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/AlbumsView/AlbumsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 27.05.2022. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct AlbumsView: View { 8 | 9 | @EnvironmentObject private var selectionService: SelectionService 10 | @Environment(\.mediaPickerTheme) private var theme 11 | 12 | @StateObject var viewModel: AlbumsViewModel 13 | @ObservedObject var mediaPickerViewModel: MediaPickerViewModel 14 | @ObservedObject var permissionsService = PermissionsService.shared 15 | 16 | @Binding var showingCamera: Bool 17 | @Binding var currentFullscreenMedia: Media? 18 | 19 | let selectionParamsHolder: SelectionParamsHolder 20 | let filterClosure: MediaPicker.FilterClosure? 21 | let massFilterClosure: MediaPicker.MassFilterClosure? 22 | 23 | @State private var showingLoadingCell = false 24 | 25 | private var cellPadding: EdgeInsets { 26 | EdgeInsets(top: 2, leading: 2, bottom: 8, trailing: 2) 27 | } 28 | 29 | var body: some View { 30 | ScrollView { 31 | VStack { 32 | PermissionActionView(type: .library(permissionsService.photoLibraryPermissionStatus)) 33 | 34 | if viewModel.isLoading { 35 | ProgressView() 36 | .padding() 37 | } else if viewModel.albums.isEmpty { 38 | Text("Empty data") 39 | .font(.title3) 40 | .foregroundColor(theme.main.pickerText) 41 | } else { 42 | let (columnWidth, columns) = calculateColumnWidth(spacing: 0) 43 | LazyVGrid(columns: columns, spacing: 0) { 44 | ForEach(viewModel.albums) { album in 45 | AlbumCell(viewModel: AlbumCellViewModel(album: album), size: columnWidth) 46 | .padding(cellPadding) 47 | .onTapGesture { 48 | mediaPickerViewModel.setPickerMode(.album(album.toAlbum())) 49 | } 50 | } 51 | } 52 | } 53 | Spacer() 54 | } 55 | } 56 | .onAppear { 57 | viewModel.onStart() 58 | } 59 | .onDisappear { 60 | viewModel.onStop() 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/AlbumsView/AlbumsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 07.06.2022. 3 | // 4 | 5 | import Foundation 6 | 7 | @MainActor 8 | final class AlbumsViewModel: ObservableObject { 9 | 10 | var albums: [AlbumModel] { 11 | albumsProvider.albums 12 | } 13 | 14 | var isLoading: Bool { 15 | albumsProvider.isLoading 16 | } 17 | 18 | private let albumsProvider: DefaultAlbumsProvider 19 | 20 | init(albumsProvider: DefaultAlbumsProvider) { 21 | self.albumsProvider = albumsProvider 22 | } 23 | 24 | func onStart() { 25 | albumsProvider.reload() 26 | } 27 | 28 | func onStop() { 29 | albumsProvider.cancelReload() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/Camera/CameraSelectionContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraSelectionContainer.swift 3 | // 4 | // 5 | // Created by Alisa Mylnikova on 12.07.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct CameraSelectionView: View { 11 | 12 | @EnvironmentObject private var cameraSelectionService: CameraSelectionService 13 | @State private var index: Int? = 0 14 | 15 | var selectionParamsHolder: SelectionParamsHolder 16 | 17 | public var body: some View { 18 | GeometryReader { g in 19 | let size = g.size 20 | if #available(iOS 17.0, *) { 21 | ScrollView(.horizontal, showsIndicators: false) { 22 | LazyHStack(spacing: 0) { 23 | ForEach(0..: View { 9 | 10 | @EnvironmentObject private var cameraSelectionService: CameraSelectionService 11 | 12 | public typealias CameraViewClosure = ((LiveCameraView, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure) -> CameraViewContent) 13 | 14 | // params 15 | @ObservedObject var viewModel: MediaPickerViewModel 16 | let didTakePicture: () -> Void 17 | let didPressCancel: () -> Void 18 | var cameraViewBuilder: CameraViewClosure 19 | 20 | @StateObject private var cameraViewModel = CameraViewModel() 21 | 22 | var body: some View { 23 | cameraViewBuilder( 24 | LiveCameraView( 25 | session: cameraViewModel.captureSession, 26 | videoGravity: .resizeAspectFill, 27 | orientation: .portrait 28 | ), 29 | { // cancel 30 | if cameraSelectionService.hasSelected { 31 | viewModel.showingExitCameraConfirmation = true 32 | } else { 33 | didPressCancel() 34 | } 35 | }, 36 | { viewModel.setPickerMode(.cameraSelection) }, // show preview of taken photos 37 | { Task { await cameraViewModel.takePhoto() } }, // takePhoto 38 | { Task { await cameraViewModel.startVideoCapture() } }, // start record video 39 | { Task { await cameraViewModel.stopVideoCapture() } }, // stop record video 40 | { Task { await cameraViewModel.toggleFlash() } }, // flash off/on 41 | { Task { await cameraViewModel.flipCamera() } } // camera back/front 42 | ) 43 | .onChange(of: cameraViewModel.capturedPhoto) { _ , newValue in 44 | viewModel.pickedMediaUrl = newValue 45 | didTakePicture() 46 | } 47 | } 48 | } 49 | 50 | struct StandardConrolsCameraView: View { 51 | 52 | @EnvironmentObject private var cameraSelectionService: CameraSelectionService 53 | @Environment(\.mediaPickerTheme) private var theme 54 | @Environment(\.scenePhase) private var scenePhase 55 | 56 | @ObservedObject var viewModel: MediaPickerViewModel 57 | let didTakePicture: () -> Void 58 | let didPressCancel: () -> Void 59 | let selectionParamsHolder: SelectionParamsHolder 60 | 61 | @StateObject private var cameraViewModel = CameraViewModel() 62 | 63 | @State private var capturingPhotos = true 64 | @State private var videoCaptureInProgress = false 65 | 66 | var body: some View { 67 | VStack(spacing: 0) { 68 | HStack { 69 | Button("Cancel") { 70 | if cameraSelectionService.hasSelected { 71 | viewModel.showingExitCameraConfirmation = true 72 | } else { 73 | didPressCancel() 74 | } 75 | } 76 | .foregroundColor(theme.main.cameraText) 77 | .padding(12, 18) 78 | 79 | Spacer() 80 | } 81 | .safeAreaPadding(.top, UIApplication.safeArea.top) 82 | 83 | LiveCameraView( 84 | session: cameraViewModel.captureSession, 85 | videoGravity: .resizeAspectFill, 86 | orientation: .portrait 87 | ) 88 | .overlay { 89 | if cameraViewModel.snapOverlay { 90 | Rectangle() 91 | } 92 | } 93 | .applyIf(cameraViewModel.zoomAllowed) { 94 | $0.gesture( 95 | MagnificationGesture() 96 | .onChanged(cameraViewModel.zoomChanged(_:)) 97 | .onEnded(cameraViewModel.zoomEnded(_:)) 98 | ) 99 | } 100 | 101 | VStack(spacing: 10) { 102 | if cameraSelectionService.hasSelected { 103 | HStack { 104 | Button("Done") { 105 | if cameraSelectionService.hasSelected { 106 | viewModel.setPickerMode(.cameraSelection) 107 | } 108 | } 109 | Spacer() 110 | if selectionParamsHolder.mediaType.allowsVideo { 111 | photoVideoToggle 112 | } 113 | Spacer() 114 | Text("\(cameraSelectionService.selected.count)") 115 | .font(.system(size: 15)) 116 | .foregroundStyle(theme.main.cameraText) 117 | .padding(8) 118 | .overlay(Circle() 119 | .stroke(theme.main.cameraText, lineWidth: 2)) 120 | } 121 | .foregroundColor(theme.main.cameraText) 122 | .padding(.horizontal, 12) 123 | } 124 | else if selectionParamsHolder.mediaType.allowsVideo { 125 | photoVideoToggle 126 | .padding(.bottom, 8) 127 | } 128 | 129 | HStack(spacing: 40) { 130 | AsyncButton { 131 | await cameraViewModel.toggleFlash() 132 | } label: { 133 | Image(cameraViewModel.flashEnabled ? "FlashOn" : "FlashOff", bundle: .current) 134 | } 135 | 136 | if capturingPhotos { 137 | takePhotoButton 138 | } else if !videoCaptureInProgress { 139 | startVideoCaptureButton 140 | } else { 141 | stopVideoCaptureButton 142 | } 143 | 144 | AsyncButton { 145 | await cameraViewModel.flipCamera() 146 | } label: { 147 | Image("FlipCamera", bundle: .current) 148 | } 149 | } 150 | } 151 | .padding(.top, 24) 152 | .padding(.bottom, 50) 153 | } 154 | .background(theme.main.cameraBackground) 155 | .onChange(of: scenePhase) { 156 | Task { 157 | if scenePhase == .background { 158 | await cameraViewModel.stopSession() 159 | } else if scenePhase == .active { 160 | await cameraViewModel.startSession() 161 | } 162 | } 163 | } 164 | .onChange(of: cameraViewModel.capturedPhoto) { _ , newValue in 165 | if let photo = newValue { 166 | viewModel.pickedMediaUrl = photo 167 | didTakePicture() 168 | } 169 | } 170 | } 171 | 172 | var photoVideoToggle: some View { 173 | HStack { 174 | Button("Video") { 175 | capturingPhotos = false 176 | } 177 | .foregroundColor(capturingPhotos ? Color.white : Color.yellow) 178 | 179 | Button("Photo") { 180 | capturingPhotos = true 181 | } 182 | .foregroundColor(capturingPhotos ? Color.yellow : Color.white) 183 | } 184 | } 185 | 186 | var takePhotoButton: some View { 187 | ZStack { 188 | Circle() 189 | .stroke(Color.white.opacity(0.4), lineWidth: 6) 190 | .frame(width: 72, height: 72) 191 | 192 | Button { 193 | Task { 194 | await cameraViewModel.takePhoto() 195 | } 196 | } label: { 197 | Circle() 198 | .foregroundColor(.white) 199 | .frame(width: 60, height: 60) 200 | } 201 | } 202 | } 203 | 204 | var startVideoCaptureButton: some View { 205 | ZStack { 206 | Circle() 207 | .stroke(Color.white.opacity(0.4), lineWidth: 6) 208 | .frame(width: 72, height: 72) 209 | 210 | AsyncButton { 211 | await cameraViewModel.startVideoCapture() 212 | videoCaptureInProgress = true 213 | } label: { 214 | Circle() 215 | .foregroundColor(.red) 216 | .frame(width: 60, height: 60) 217 | } 218 | } 219 | } 220 | 221 | var stopVideoCaptureButton: some View { 222 | ZStack { 223 | Circle() 224 | .stroke(Color.white.opacity(0.4), lineWidth: 6) 225 | .frame(width: 72, height: 72) 226 | 227 | AsyncButton { 228 | await cameraViewModel.stopVideoCapture() 229 | videoCaptureInProgress = false 230 | } label: { 231 | RoundedRectangle(cornerRadius: 10) 232 | .foregroundColor(.red) 233 | .frame(width: 40, height: 40) 234 | } 235 | } 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/Camera/CameraViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraViewModel.swift 3 | // 4 | // 5 | // Created by Alexandra Afonasova on 18.10.2022. 6 | // 7 | 8 | import Foundation 9 | import AVFoundation 10 | import UIKit 11 | import SwiftUI 12 | import Combine 13 | 14 | #if compiler(>=6.0) 15 | extension AVCaptureSession: @retroactive @unchecked Sendable { } 16 | #else 17 | extension AVCaptureSession: @unchecked Sendable { } 18 | #endif 19 | 20 | final actor CameraViewModel: NSObject, ObservableObject { 21 | 22 | struct CaptureDevice { 23 | let device: AVCaptureDevice 24 | let position: AVCaptureDevice.Position 25 | let defaultZoom: CGFloat 26 | let maxZoom: CGFloat 27 | } 28 | 29 | @MainActor @Published private(set) var flashEnabled = false 30 | @MainActor @Published private(set) var snapOverlay = false 31 | @MainActor @Published private(set) var zoomAllowed = false 32 | @MainActor @Published private(set) var capturedPhoto: URL? 33 | 34 | let captureSession = AVCaptureSession() 35 | 36 | private let photoOutput = AVCapturePhotoOutput() 37 | private let videoOutput = AVCaptureMovieFileOutput() 38 | private let motionManager = MotionManager() 39 | private var captureDevice: CaptureDevice? 40 | private var lastPhotoActualOrientation: UIDeviceOrientation? 41 | 42 | private let minScale: CGFloat = 1 43 | private let singleCameraMaxScale: CGFloat = 5 44 | private let dualCameraMaxScale: CGFloat = 8 45 | private let tripleCameraMaxScale: CGFloat = 12 46 | private var lastScale: CGFloat = 1 47 | 48 | override init() { 49 | super.init() 50 | Task { 51 | await configureSession() 52 | captureSession.startRunning() 53 | } 54 | } 55 | 56 | func startSession() { 57 | captureSession.startRunning() 58 | } 59 | 60 | func stopSession() { 61 | captureSession.stopRunning() 62 | } 63 | 64 | func setCapturedPhoto(_ photo: URL?) { 65 | DispatchQueue.main.async { 66 | self.capturedPhoto = photo 67 | } 68 | } 69 | 70 | func takePhoto() async { 71 | let settings = AVCapturePhotoSettings() 72 | settings.flashMode = await flashEnabled ? .on : .off 73 | photoOutput.capturePhoto(with: settings, delegate: self) 74 | lastPhotoActualOrientation = motionManager.orientation 75 | 76 | withAnimation(.linear(duration: 0.1)) { 77 | DispatchQueue.main.async { 78 | self.snapOverlay = true 79 | } 80 | } 81 | withAnimation(.linear(duration: 0.1).delay(0.1)) { 82 | DispatchQueue.main.async { 83 | self.snapOverlay = false 84 | } 85 | } 86 | } 87 | 88 | func startVideoCapture() async { 89 | setVideoTorchMode(await flashEnabled ? .on : .off) 90 | 91 | let videoUrl = FileManager.getTempUrl() 92 | videoOutput.startRecording(to: videoUrl, recordingDelegate: self) 93 | } 94 | 95 | func stopVideoCapture() { 96 | setVideoTorchMode(.off) 97 | videoOutput.stopRecording() 98 | } 99 | 100 | func setVideoTorchMode(_ mode: AVCaptureDevice.TorchMode) { 101 | if captureDevice?.device.torchMode != mode { 102 | try? captureDevice?.device.lockForConfiguration() 103 | captureDevice?.device.torchMode = mode 104 | captureDevice?.device.unlockForConfiguration() 105 | } 106 | } 107 | 108 | func flipCamera() { 109 | let session = captureSession 110 | guard let input = session.inputs.first else { 111 | return 112 | } 113 | let newPosition: AVCaptureDevice.Position = captureDevice?.position == .back ? .front : .back 114 | 115 | session.beginConfiguration() 116 | session.removeInput(input) 117 | addInput(to: session, for: newPosition) 118 | session.commitConfiguration() 119 | } 120 | 121 | func toggleFlash() { 122 | DispatchQueue.main.async { 123 | self.flashEnabled.toggle() 124 | } 125 | } 126 | 127 | nonisolated func zoomChanged(_ scale: CGFloat) { 128 | Task { 129 | await zoomCamera(await resolveScale(scale)) 130 | } 131 | } 132 | 133 | nonisolated func zoomEnded(_ scale: CGFloat) { 134 | Task { 135 | await setLastScale(await resolveScale(scale)) 136 | await zoomCamera(lastScale) 137 | } 138 | } 139 | 140 | private func setLastScale(_ scale: CGFloat) { 141 | self.lastScale = scale 142 | } 143 | 144 | private func resolveScale(_ gestureScale: CGFloat) -> CGFloat { 145 | let newScale = lastScale * gestureScale 146 | let maxScale = captureDevice?.maxZoom ?? singleCameraMaxScale 147 | return max(min(maxScale, newScale), minScale) 148 | } 149 | 150 | private func zoomCamera(_ scale: CGFloat) { 151 | do { 152 | try captureDevice?.device.lockForConfiguration() 153 | captureDevice?.device.videoZoomFactor = scale 154 | captureDevice?.device.unlockForConfiguration() 155 | } catch {} 156 | } 157 | 158 | private func configureSession() { 159 | captureSession.beginConfiguration() 160 | captureSession.sessionPreset = .photo 161 | addInput(to: captureSession) 162 | addOutput(to: captureSession) 163 | captureSession.commitConfiguration() 164 | } 165 | 166 | private func addInput(to session: AVCaptureSession, for position: AVCaptureDevice.Position = .back) { 167 | guard let captureDevice = selectCaptureDevice(for: position) else { return } 168 | let zoomAllowed = captureDevice.position == .back 169 | Task { @MainActor in 170 | self.zoomAllowed = zoomAllowed 171 | } 172 | guard let captureDeviceInput = try? AVCaptureDeviceInput(device: captureDevice) else { return } 173 | guard session.canAddInput(captureDeviceInput) else { return } 174 | session.addInput(captureDeviceInput) 175 | 176 | guard let captureAudioDevice = selectAudioCaptureDevice() else { return } 177 | guard let captureAudioDeviceInput = try? AVCaptureDeviceInput(device: captureAudioDevice) else { return } 178 | guard session.canAddInput(captureAudioDeviceInput) else { return } 179 | session.addInput(captureAudioDeviceInput) 180 | 181 | let defaultZoom = CGFloat(truncating: captureDevice.virtualDeviceSwitchOverVideoZoomFactors.first ?? minScale as NSNumber) 182 | 183 | let maxZoom: CGFloat 184 | let cameraCount = captureDevice.virtualDeviceSwitchOverVideoZoomFactors.count + 1 185 | switch cameraCount { 186 | case 1: maxZoom = singleCameraMaxScale 187 | case 2: maxZoom = dualCameraMaxScale 188 | default: maxZoom = tripleCameraMaxScale 189 | } 190 | 191 | let device = CaptureDevice( 192 | device: captureDevice, 193 | position: position, 194 | defaultZoom: defaultZoom, 195 | maxZoom: maxZoom 196 | ) 197 | self.captureDevice = device 198 | 199 | if position == .back { 200 | captureDeviceInput.device.videoZoomFactor = device.defaultZoom 201 | lastScale = device.defaultZoom 202 | } 203 | } 204 | 205 | private func addOutput(to session: AVCaptureSession) { 206 | photoOutput.isLivePhotoCaptureEnabled = false 207 | guard session.canAddOutput(photoOutput) else { return } 208 | session.addOutput(photoOutput) 209 | 210 | guard session.canAddOutput(videoOutput) else { return } 211 | session.addOutput(videoOutput) 212 | 213 | updateOutputOrientation(photoOutput) 214 | updateOutputOrientation(videoOutput) 215 | } 216 | 217 | private func updateOutputOrientation(_ output: AVCaptureOutput) { 218 | guard let connection = output.connection(with: .video) else { return } 219 | if connection.isVideoRotationAngleSupported(0) { 220 | connection.videoRotationAngle = 0 221 | } 222 | } 223 | 224 | private func selectCaptureDevice(for position: AVCaptureDevice.Position) -> AVCaptureDevice? { 225 | let session = AVCaptureDevice.DiscoverySession( 226 | deviceTypes: [ 227 | .builtInDualCamera, 228 | .builtInDualWideCamera, 229 | .builtInTripleCamera, 230 | .builtInTelephotoCamera, 231 | .builtInTrueDepthCamera, 232 | .builtInUltraWideCamera, 233 | .builtInWideAngleCamera 234 | ], 235 | mediaType: .video, 236 | position: position) 237 | 238 | if let camera = session.devices.first(where: { $0.deviceType == .builtInTripleCamera }) { 239 | return camera 240 | } else if let camera = session.devices.first(where: { $0.deviceType == .builtInDualCamera }) { 241 | return camera 242 | } else { 243 | return session.devices.first 244 | } 245 | } 246 | 247 | private func selectAudioCaptureDevice() -> AVCaptureDevice? { 248 | let session = AVCaptureDevice.DiscoverySession( 249 | deviceTypes: [.microphone], 250 | mediaType: .audio, 251 | position: .unspecified) 252 | 253 | return session.devices.first 254 | } 255 | } 256 | 257 | extension CameraViewModel: AVCapturePhotoCaptureDelegate { 258 | nonisolated func photoOutput( 259 | _ output: AVCapturePhotoOutput, 260 | didFinishProcessingPhoto photo: AVCapturePhoto, 261 | error: Error? 262 | ) { 263 | guard let cgImage = photo.cgImageRepresentation() else { return } 264 | 265 | Task { 266 | let photoOrientation: UIImage.Orientation 267 | if let orientation = await lastPhotoActualOrientation { 268 | photoOrientation = UIImage.Orientation(orientation) 269 | } else { 270 | photoOrientation = UIImage.Orientation.default 271 | } 272 | 273 | guard let data = UIImage( 274 | cgImage: cgImage, 275 | scale: 1, 276 | orientation: photoOrientation 277 | ).jpegData(compressionQuality: 0.8) else { return } 278 | 279 | await setCapturedPhoto(FileManager.storeToTempDir(data: data)) 280 | } 281 | } 282 | } 283 | 284 | extension CameraViewModel: AVCaptureFileOutputRecordingDelegate { 285 | nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { 286 | Task { 287 | await setCapturedPhoto(outputFileURL) 288 | } 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/Camera/LiveCameraCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 06.06.2022. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct LiveCameraCell: View { 8 | 9 | @Environment(\.scenePhase) private var scenePhase 10 | 11 | let action: () -> Void 12 | 13 | @StateObject private var cameraViewModel = CameraViewModel() 14 | @State private var orientation = UIDevice.current.orientation 15 | 16 | var body: some View { 17 | Button { 18 | action() 19 | } label: { 20 | LiveCameraView( 21 | session: cameraViewModel.captureSession, 22 | videoGravity: .resizeAspectFill, 23 | orientation: orientation 24 | ) 25 | .overlay( 26 | Image(systemName: "camera") 27 | .foregroundColor(.white) 28 | ) 29 | } 30 | .onChange(of: scenePhase) { 31 | Task { 32 | if scenePhase == .background { 33 | await cameraViewModel.stopSession() 34 | } else if scenePhase == .active { 35 | await cameraViewModel.startSession() 36 | } 37 | } 38 | } 39 | .onRotate { orientation = $0 } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/Camera/LiveCameraView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LiveCameraView.swift 3 | // 4 | // 5 | // Created by Alexandra Afonasova on 18.10.2022. 6 | // 7 | 8 | import SwiftUI 9 | import AVFoundation 10 | 11 | @MainActor 12 | public struct LiveCameraView: UIViewRepresentable { 13 | 14 | let session: AVCaptureSession 15 | let videoGravity: AVLayerVideoGravity 16 | let orientation: UIDeviceOrientation 17 | 18 | public func makeUIView(context: Context) -> LiveVideoCaptureView { 19 | LiveVideoCaptureView( 20 | session: session, 21 | videoGravity: videoGravity, 22 | orientation: orientation 23 | ) 24 | } 25 | 26 | public func updateUIView(_ uiView: LiveVideoCaptureView, context: Context) { } 27 | } 28 | 29 | public final class LiveVideoCaptureView: UIView { 30 | 31 | var session: AVCaptureSession? { 32 | get { 33 | return videoLayer.session 34 | } 35 | set (session) { 36 | videoLayer.session = session 37 | } 38 | } 39 | 40 | public override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } 41 | 42 | private var videoLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } 43 | 44 | required init?(coder: NSCoder) { 45 | super.init(coder: coder) 46 | } 47 | 48 | init( 49 | frame: CGRect = .zero, 50 | session: AVCaptureSession? = nil, 51 | videoGravity: AVLayerVideoGravity = .resizeAspect, 52 | orientation: UIDeviceOrientation 53 | ) { 54 | super.init(frame: frame) 55 | self.session = session 56 | videoLayer.videoGravity = videoGravity 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/Fullscreen/FullscreenCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 09.06.2022. 3 | // 4 | 5 | import Foundation 6 | import SwiftUI 7 | import AVKit 8 | 9 | struct FullscreenCell: View { 10 | 11 | @Environment(\.mediaPickerTheme) private var theme 12 | 13 | @StateObject var viewModel: FullscreenCellViewModel 14 | @ObservedObject var keyboardHeightHelper = KeyboardHeightHelper.shared 15 | 16 | var size: CGSize 17 | 18 | var body: some View { 19 | Group { 20 | if let image = viewModel.image { 21 | ZoomableScrollView { 22 | imageView(image: image, useFill: false) 23 | } 24 | } else if let player = viewModel.player { 25 | ZoomableScrollView { 26 | videoView(player: player, useFill: false) 27 | } 28 | } else { 29 | ProgressView() 30 | .tint(.white) 31 | } 32 | } 33 | .allowsHitTesting(!keyboardHeightHelper.keyboardDisplayed) 34 | .task { 35 | await viewModel.onStart() 36 | } 37 | .onDisappear { 38 | viewModel.onStop() 39 | } 40 | } 41 | 42 | @ViewBuilder 43 | func imageView(image: UIImage, useFill: Bool) -> some View { 44 | Image(uiImage: image) 45 | .resizable() 46 | .aspectRatio(contentMode: useFill ? .fill : .fit) 47 | } 48 | 49 | func videoView(player: AVPlayer, useFill: Bool) -> some View { 50 | PlayerView(player: player, bgColor: theme.main.fullscreenPhotoBackground, useFill: useFill) 51 | .disabled(true) 52 | .overlay { 53 | ZStack { 54 | Color.clear 55 | if !viewModel.isPlaying { 56 | Circle().styled(.black.opacity(0.2)) 57 | .frame(width: 70, height: 70) 58 | Image(systemName: "play.fill") 59 | .resizable() 60 | .foregroundColor(.white.opacity(0.8)) 61 | .frame(width: 30, height: 30) 62 | .padding(.leading, 4) 63 | } 64 | } 65 | .contentShape(Rectangle()) 66 | .simultaneousGesture( 67 | TapGesture().onEnded { 68 | viewModel.togglePlay() 69 | } 70 | ) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/Fullscreen/FullscreenCellViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 09.06.2022. 3 | // 4 | 5 | import Foundation 6 | @preconcurrency import AVKit 7 | import UIKit.UIImage 8 | 9 | #if compiler(>=6.0) 10 | extension AVAssetTrack: @retroactive @unchecked Sendable { } 11 | #else 12 | extension AVAssetTrack: @unchecked Sendable { } 13 | #endif 14 | 15 | @MainActor 16 | final class FullscreenCellViewModel: ObservableObject { 17 | 18 | let mediaModel: MediaModelProtocol 19 | 20 | @Published var image: UIImage? = nil 21 | @Published var player: AVPlayer? = nil 22 | @Published var isPlaying = false 23 | @Published var videoSize: CGSize = .zero 24 | 25 | private var currentTask: Task? 26 | 27 | init(mediaModel: MediaModelProtocol) { 28 | self.mediaModel = mediaModel 29 | } 30 | 31 | func onStart() async { 32 | guard image == nil || player == nil else { return } 33 | 34 | currentTask?.cancel() 35 | currentTask = Task { 36 | switch self.mediaModel.mediaType { 37 | case .image: 38 | let data = try? await mediaModel.getData() // url is slow to load in UI, this way photos don't flicker when swiping 39 | guard let data = data else { return } 40 | let result = UIImage(data: data) 41 | DispatchQueue.main.async { 42 | self.image = result 43 | } 44 | case .video: 45 | let url = await mediaModel.getURL() 46 | guard let url = url else { return } 47 | setupPlayer(url) 48 | videoSize = await getVideoSize(url) 49 | case .none: 50 | break 51 | } 52 | } 53 | } 54 | 55 | func setupPlayer(_ url: URL) { 56 | DispatchQueue.main.async { 57 | self.player = AVPlayer(url: url) 58 | } 59 | NotificationCenter.default.addObserver(self, selector: #selector(finishVideo), name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: nil) 60 | } 61 | 62 | @objc func finishVideo() { 63 | player?.seek(to: CMTime(seconds: 0, preferredTimescale: 10)) 64 | isPlaying = false 65 | } 66 | 67 | func onStop() { 68 | currentTask = nil 69 | image = nil 70 | player = nil 71 | isPlaying = false 72 | } 73 | 74 | func togglePlay() { 75 | if isPlaying { 76 | player?.pause() 77 | } else { 78 | player?.play() 79 | } 80 | isPlaying = !isPlaying 81 | } 82 | 83 | func getVideoSize(_ url: URL) async -> CGSize { 84 | let videoAsset = AVURLAsset(url : url) 85 | 86 | let videoAssetTrack = try? await videoAsset.loadTracks(withMediaType: .video).first 87 | let naturalSize = (try? await videoAssetTrack?.load(.naturalSize)) ?? .zero 88 | let transform = try? await videoAssetTrack?.load(.preferredTransform) 89 | if (transform?.tx == naturalSize.width && transform?.ty == naturalSize.height) || (transform?.tx == 0 && transform?.ty == 0) { 90 | return naturalSize 91 | } else { 92 | return CGSize(width: naturalSize.height, height: naturalSize.width) 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/Fullscreen/FullscreenContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 09.06.2022. 3 | // 4 | 5 | import Foundation 6 | import SwiftUI 7 | import AnchoredPopup 8 | 9 | struct FullscreenContainer: View { 10 | 11 | @EnvironmentObject private var selectionService: SelectionService 12 | @Environment(\.mediaPickerTheme) private var theme 13 | 14 | @ObservedObject var keyboardHeightHelper = KeyboardHeightHelper.shared 15 | 16 | @Binding var currentFullscreenMedia: Media? 17 | @Binding var selection: AssetMediaModel.ID? 18 | let animationID: String 19 | let assetMediaModels: [AssetMediaModel] 20 | var selectionParamsHolder: SelectionParamsHolder 21 | var dismiss: ()->() 22 | 23 | private var selectedMediaModel: AssetMediaModel? { 24 | assetMediaModels.first { $0.id == selection } 25 | } 26 | 27 | private var selectionServiceIndex: Int? { 28 | guard let selectedMediaModel = selectedMediaModel else { 29 | return nil 30 | } 31 | return selectionService.index(of: selectedMediaModel) 32 | } 33 | 34 | var body: some View { 35 | VStack { 36 | controlsOverlay 37 | GeometryReader { g in 38 | contentView(g.size) 39 | } 40 | } 41 | .safeAreaPadding(.top, UIApplication.safeArea.top) 42 | .background { 43 | theme.main.fullscreenPhotoBackground 44 | .ignoresSafeArea() 45 | } 46 | .onAppear { 47 | if let selectedMediaModel { 48 | currentFullscreenMedia = Media(source: selectedMediaModel) 49 | } 50 | } 51 | .onDisappear { 52 | currentFullscreenMedia = nil 53 | } 54 | .onChange(of: selection) { 55 | if let selectedMediaModel { 56 | currentFullscreenMedia = Media(source: selectedMediaModel) 57 | } 58 | } 59 | .onTapGesture { 60 | if keyboardHeightHelper.keyboardDisplayed { 61 | dismissKeyboard() 62 | } else { 63 | if let selectedMediaModel = selectedMediaModel, selectedMediaModel.mediaType == .image { 64 | selectionService.onSelect(assetMediaModel: selectedMediaModel) 65 | } 66 | } 67 | } 68 | } 69 | 70 | @ViewBuilder 71 | func contentView(_ size: CGSize) -> some View { 72 | if #available(iOS 17.0, *) { 73 | ScrollViewReader { scrollReader in 74 | ScrollView(.horizontal, showsIndicators: false) { 75 | LazyHStack(spacing: 0) { 76 | ForEach(assetMediaModels, id: \.id) { assetMediaModel in 77 | FullscreenCell(viewModel: FullscreenCellViewModel(mediaModel: assetMediaModel), size: size) 78 | .frame(width: size.width, height: size.height) 79 | .id(assetMediaModel.id) 80 | } 81 | } 82 | .scrollTargetLayout() 83 | } 84 | .scrollTargetBehavior(.viewAligned) 85 | .scrollPosition(id: $selection) 86 | .onAppear { 87 | scrollReader.scrollTo(selection) 88 | } 89 | } 90 | } else { 91 | TabView(selection: $selection) { 92 | ForEach(assetMediaModels, id: \.id) { assetMediaModel in 93 | FullscreenCell(viewModel: FullscreenCellViewModel(mediaModel: assetMediaModel), size: size) 94 | .frame(maxWidth: .infinity, maxHeight: .infinity) 95 | .tag(assetMediaModel.id) 96 | } 97 | } 98 | } 99 | } 100 | 101 | var controlsOverlay: some View { 102 | HStack { 103 | Image(systemName: "xmark") 104 | .resizable() 105 | .frame(width: 20, height: 20) 106 | .padding(20, 16) 107 | .contentShape(Rectangle()) 108 | .onTapGesture { 109 | selection = nil 110 | AnchoredPopup.launchShrinkingAnimation(id: animationID) 111 | } 112 | 113 | Spacer() 114 | 115 | if let selectedMediaModel = selectedMediaModel { 116 | if selectionParamsHolder.selectionLimit == 1 { 117 | Button("Select") { 118 | selectionService.onSelect(assetMediaModel: selectedMediaModel) 119 | dismiss() 120 | } 121 | .padding(.horizontal, 20) 122 | } else { 123 | SelectionIndicatorView(index: selectionServiceIndex, isFullscreen: true, canSelect: selectionService.canSelect(assetMediaModel: selectedMediaModel), selectionParamsHolder: selectionParamsHolder) 124 | .padding(.horizontal, 20) 125 | .onTapGesture { 126 | selectionService.onSelect(assetMediaModel: selectedMediaModel) // for video selection, since tap on video is toggle play 127 | } 128 | } 129 | } 130 | } 131 | .foregroundStyle(theme.selection.fullscreenSelectedBackground) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/MediaPicker/AlbumSelectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumSelectionView.swift 3 | // 4 | // 5 | // Created by Alisa Mylnikova on 08.02.2023. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct AlbumSelectionView: View { 11 | 12 | @ObservedObject var viewModel: MediaPickerViewModel 13 | 14 | @Binding var showingCamera: Bool 15 | @Binding var currentFullscreenMedia: Media? 16 | 17 | let showingLiveCameraCell: Bool 18 | let selectionParamsHolder: SelectionParamsHolder 19 | let filterClosure: MediaPicker.FilterClosure? 20 | let massFilterClosure: MediaPicker.MassFilterClosure? 21 | var dismiss: ()->() 22 | 23 | public var body: some View { 24 | switch viewModel.internalPickerMode { 25 | case .photos: 26 | AlbumView( 27 | viewModel: AllMediasProvider(selectionParamsHolder: selectionParamsHolder, filterClosure: filterClosure, massFilterClosure: massFilterClosure), 28 | showingCamera: $showingCamera, 29 | currentFullscreenMedia: $currentFullscreenMedia, 30 | shouldShowCamera: showingLiveCameraCell, 31 | selectionParamsHolder: selectionParamsHolder, 32 | dismiss: dismiss 33 | ) 34 | case .albums: 35 | AlbumsView( 36 | viewModel: AlbumsViewModel( 37 | albumsProvider: viewModel.defaultAlbumsProvider 38 | ), 39 | mediaPickerViewModel: viewModel, 40 | showingCamera: $showingCamera, 41 | currentFullscreenMedia: $currentFullscreenMedia, 42 | selectionParamsHolder: selectionParamsHolder, 43 | filterClosure: filterClosure, 44 | massFilterClosure: massFilterClosure 45 | ) 46 | .onAppear { 47 | viewModel.defaultAlbumsProvider.mediaSelectionType = selectionParamsHolder.mediaType 48 | } 49 | case .album(let album): 50 | if let albumModel = viewModel.getAlbumModel(album) { 51 | AlbumView( 52 | viewModel: AlbumMediasProvider(album: albumModel, selectionParamsHolder: selectionParamsHolder, filterClosure: filterClosure, massFilterClosure: massFilterClosure), 53 | showingCamera: $showingCamera, 54 | currentFullscreenMedia: $currentFullscreenMedia, 55 | shouldShowCamera: false, 56 | selectionParamsHolder: selectionParamsHolder, 57 | dismiss: dismiss 58 | ) 59 | .id(album.id) 60 | } 61 | default: 62 | EmptyView() 63 | } 64 | } 65 | } 66 | 67 | public struct ModeSwitcher: View { 68 | 69 | @Binding var selection: Int 70 | 71 | public var body: some View { 72 | Picker("", selection: $selection) { 73 | Text("Photos") 74 | .tag(0) 75 | Text("Albums") 76 | .tag(1) 77 | } 78 | .pickerStyle(SegmentedPickerStyle()) 79 | .frame(maxWidth: UIScreen.main.bounds.width / 2) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/MediaPicker/GenenricsTrick.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by Alisa Mylnikova on 18.10.2023. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // MARK: - Partial genereic specification imitation 11 | 12 | public extension MediaPicker where AlbumSelectionContent == EmptyView, CameraSelectionContent == EmptyView, CameraViewContent == EmptyView { 13 | 14 | init(isPresented: Binding, 15 | onChange: @escaping MediaPickerCompletionClosure) { 16 | 17 | self.init(isPresented: isPresented, 18 | onChange: onChange, 19 | albumSelectionBuilder: nil, 20 | cameraSelectionBuilder: nil, 21 | cameraViewBuilder: nil) 22 | } 23 | } 24 | 25 | public extension MediaPicker where CameraSelectionContent == EmptyView, CameraViewContent == EmptyView { 26 | 27 | init(isPresented: Binding, 28 | onChange: @escaping MediaPickerCompletionClosure, 29 | albumSelectionBuilder: @escaping AlbumSelectionClosure) { 30 | 31 | self.init(isPresented: isPresented, 32 | onChange: onChange, 33 | albumSelectionBuilder: albumSelectionBuilder, 34 | cameraSelectionBuilder: nil, 35 | cameraViewBuilder: nil) 36 | } 37 | } 38 | 39 | public extension MediaPicker where AlbumSelectionContent == EmptyView, CameraViewContent == EmptyView { 40 | 41 | init(isPresented: Binding, 42 | onChange: @escaping MediaPickerCompletionClosure, 43 | cameraSelectionBuilder: @escaping CameraSelectionClosure) { 44 | 45 | self.init(isPresented: isPresented, 46 | onChange: onChange, 47 | albumSelectionBuilder: nil, 48 | cameraSelectionBuilder: cameraSelectionBuilder, 49 | cameraViewBuilder: nil) 50 | } 51 | } 52 | 53 | public extension MediaPicker where AlbumSelectionContent == EmptyView, CameraSelectionContent == EmptyView { 54 | 55 | init(isPresented: Binding, 56 | onChange: @escaping MediaPickerCompletionClosure, 57 | cameraViewBuilder: @escaping CameraViewClosure) { 58 | 59 | self.init(isPresented: isPresented, 60 | onChange: onChange, 61 | albumSelectionBuilder: nil, 62 | cameraSelectionBuilder: nil, 63 | cameraViewBuilder: cameraViewBuilder) 64 | } 65 | } 66 | 67 | public extension MediaPicker where CameraViewContent == EmptyView { 68 | 69 | init(isPresented: Binding, 70 | onChange: @escaping MediaPickerCompletionClosure, 71 | albumSelectionBuilder: @escaping AlbumSelectionClosure, 72 | cameraSelectionBuilder: @escaping CameraSelectionClosure) { 73 | 74 | self.init(isPresented: isPresented, 75 | onChange: onChange, 76 | albumSelectionBuilder: albumSelectionBuilder, 77 | cameraSelectionBuilder: cameraSelectionBuilder, 78 | cameraViewBuilder: nil) 79 | } 80 | } 81 | 82 | public extension MediaPicker where CameraViewContent == EmptyView { 83 | 84 | init(isPresented: Binding, 85 | onChange: @escaping MediaPickerCompletionClosure, 86 | albumSelectionBuilder: @escaping AlbumSelectionClosure, 87 | cameraViewBuilder: @escaping CameraViewClosure) { 88 | 89 | self.init(isPresented: isPresented, 90 | onChange: onChange, 91 | albumSelectionBuilder: albumSelectionBuilder, 92 | cameraSelectionBuilder: nil, 93 | cameraViewBuilder: cameraViewBuilder) 94 | } 95 | } 96 | 97 | public extension MediaPicker where AlbumSelectionContent == EmptyView { 98 | 99 | init(isPresented: Binding, 100 | onChange: @escaping MediaPickerCompletionClosure, 101 | cameraSelectionBuilder: @escaping CameraSelectionClosure, 102 | cameraViewBuilder: @escaping CameraViewClosure) { 103 | 104 | self.init(isPresented: isPresented, 105 | onChange: onChange, 106 | albumSelectionBuilder: nil, 107 | cameraSelectionBuilder: cameraSelectionBuilder, 108 | cameraViewBuilder: cameraViewBuilder) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/MediaPicker/MediaPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 26.05.2022. 3 | // 4 | 5 | import SwiftUI 6 | 7 | public struct MediaPicker: View { 8 | 9 | /// To provide custom buttons layout for photos grid view use actions and views provided by this closure: 10 | /// - standard header with photos/albums switcher 11 | /// - selection view you can embed in your view 12 | /// - is in fullscreen photo details mode 13 | public typealias AlbumSelectionClosure = ((ModeSwitcher, AlbumSelectionView, Bool) -> AlbumSelectionContent) 14 | 15 | /// To provide custom buttons layout for camera selection view use actions and views provided by this closure: 16 | /// - add more photos closure 17 | /// - cancel closure 18 | /// - selection view you can embed in your view 19 | public typealias CameraSelectionClosure = ((@escaping SimpleClosure, @escaping SimpleClosure, CameraSelectionView) -> CameraSelectionContent) 20 | 21 | /// To provide custom buttons layout for camera view use actions and views provided by this closure: 22 | /// - live camera capture view 23 | /// - cancel closure 24 | /// - show preview of taken photos 25 | /// - take photo closure 26 | /// - start record video closure 27 | /// - stop record video closure 28 | /// - flash off/on closure 29 | /// - camera back/front closure 30 | public typealias CameraViewClosure = ((LiveCameraView, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure) -> CameraViewContent) 31 | 32 | public typealias FilterClosure = @Sendable (Media) async -> Media? 33 | public typealias MassFilterClosure = @Sendable ([Media]) async -> [Media] 34 | 35 | // MARK: - Parameters 36 | 37 | @Binding private var isPresented: Bool 38 | private let onChange: MediaPickerCompletionClosure 39 | 40 | // MARK: - View builders 41 | 42 | private var albumSelectionBuilder: AlbumSelectionClosure? = nil 43 | private var cameraSelectionBuilder: CameraSelectionClosure? = nil 44 | private var cameraViewBuilder: CameraViewClosure? = nil 45 | 46 | // MARK: - Customization 47 | 48 | @Binding private var albums: [Album] 49 | @Binding private var currentFullscreenMediaBinding: Media? 50 | 51 | private var pickerMode: Binding? 52 | private var showingLiveCameraCell: Bool = false 53 | private var didPressCancelCamera: (() -> Void)? 54 | private var orientationHandler: MediaPickerOrientationHandler = {_ in} 55 | private var filterClosure: FilterClosure? 56 | private var massFilterClosure: MassFilterClosure? 57 | private var selectionParamsHolder = SelectionParamsHolder() 58 | 59 | // MARK: - Inner values 60 | 61 | @Environment(\.mediaPickerTheme) private var theme 62 | 63 | @StateObject private var viewModel = MediaPickerViewModel() 64 | @StateObject private var selectionService = SelectionService() 65 | @StateObject private var cameraSelectionService = CameraSelectionService() 66 | 67 | @State private var readyToShowCamera = false 68 | @State private var currentFullscreenMedia: Media? 69 | 70 | @State private var internalPickerMode: MediaPickerMode = .photos // a hack for slow camera dismissal 71 | 72 | var isInFullscreen: Bool { 73 | currentFullscreenMedia != nil 74 | } 75 | 76 | // MARK: - Object life cycle 77 | 78 | public init(isPresented: Binding, 79 | onChange: @escaping MediaPickerCompletionClosure, 80 | albumSelectionBuilder: AlbumSelectionClosure? = nil, 81 | cameraSelectionBuilder: CameraSelectionClosure? = nil, 82 | cameraViewBuilder: CameraViewClosure? = nil) { 83 | 84 | self._isPresented = isPresented 85 | self._albums = .constant([]) 86 | self._currentFullscreenMediaBinding = .constant(nil) 87 | 88 | self.onChange = onChange 89 | self.albumSelectionBuilder = albumSelectionBuilder 90 | self.cameraSelectionBuilder = cameraSelectionBuilder 91 | self.cameraViewBuilder = cameraViewBuilder 92 | } 93 | 94 | public var body: some View { 95 | Group { 96 | switch internalPickerMode { // please don't use viewModel.internalPickerMode here - it slows down camera dismissal 97 | case .photos, .albums, .album(_): 98 | albumSelectionContainer 99 | case .camera: 100 | cameraContainer 101 | case .cameraSelection: 102 | cameraSelectionContainer 103 | } 104 | } 105 | .background(theme.main.pickerBackground.ignoresSafeArea()) 106 | .environmentObject(selectionService) 107 | .environmentObject(cameraSelectionService) 108 | .onAppear { 109 | PermissionsService.shared.updatePhotoLibraryAuthorizationStatus() 110 | #if !targetEnvironment(simulator) 111 | if showingLiveCameraCell { 112 | PermissionsService.shared.requestCameraPermission() 113 | } else { 114 | PermissionsService.shared.updateCameraAuthorizationStatus() 115 | } 116 | #endif 117 | 118 | selectionService.onChange = onChange 119 | selectionService.mediaSelectionLimit = selectionParamsHolder.selectionLimit 120 | 121 | cameraSelectionService.onChange = onChange 122 | cameraSelectionService.mediaSelectionLimit = selectionParamsHolder.selectionLimit 123 | 124 | viewModel.shouldUpdatePickerMode = { mode in 125 | pickerMode?.wrappedValue = mode 126 | } 127 | viewModel.onStart() 128 | } 129 | .onChange(of: viewModel.albums) { _ , albums in 130 | self.albums = albums.map { $0.toAlbum() } 131 | } 132 | .onChange(of: pickerMode?.wrappedValue) { _ , mode in 133 | if let mode = mode { 134 | viewModel.setPickerMode(mode) 135 | } 136 | } 137 | .onChange(of: viewModel.internalPickerMode) { _ , newValue in 138 | internalPickerMode = newValue 139 | } 140 | .onChange(of: currentFullscreenMedia) { 141 | _currentFullscreenMediaBinding.wrappedValue = currentFullscreenMedia 142 | } 143 | .onAppear { 144 | if let mode = pickerMode?.wrappedValue { 145 | viewModel.setPickerMode(mode) 146 | } 147 | } 148 | } 149 | 150 | @ViewBuilder 151 | var albumSelectionContainer: some View { 152 | let albumSelectionView = AlbumSelectionView(viewModel: viewModel, showingCamera: cameraBinding(), currentFullscreenMedia: $currentFullscreenMedia, showingLiveCameraCell: showingLiveCameraCell, selectionParamsHolder: selectionParamsHolder, filterClosure: filterClosure, massFilterClosure: massFilterClosure) { 153 | // has media limit of 1, and it's been selected 154 | isPresented = false 155 | } 156 | 157 | if let albumSelectionBuilder = albumSelectionBuilder { 158 | albumSelectionBuilder(ModeSwitcher(selection: modeBinding()), albumSelectionView, isInFullscreen) 159 | } else { 160 | VStack(spacing: 0) { 161 | defaultHeaderView 162 | albumSelectionView 163 | } 164 | } 165 | } 166 | 167 | @ViewBuilder 168 | var cameraSelectionContainer: some View { 169 | Group { 170 | if let cameraSelectionBuilder = cameraSelectionBuilder { 171 | cameraSelectionBuilder( 172 | { viewModel.setPickerMode(.camera) }, // add more 173 | { viewModel.onCancelCameraSelection(cameraSelectionService.hasSelected) }, // cancel 174 | CameraSelectionView(selectionParamsHolder: selectionParamsHolder) 175 | ) 176 | } else { 177 | DefaultCameraSelectionContainer( 178 | viewModel: viewModel, 179 | showingPicker: $isPresented, 180 | selectionParamsHolder: selectionParamsHolder 181 | ) 182 | } 183 | } 184 | .confirmationDialog("", isPresented: $viewModel.showingExitCameraConfirmation, titleVisibility: .hidden) { 185 | deleteAllButton 186 | } 187 | } 188 | 189 | @ViewBuilder 190 | var cameraContainer: some View { 191 | ZStack { 192 | theme.main.cameraBackground 193 | .ignoresSafeArea(.all) 194 | .onAppear { 195 | DispatchQueue.main.async { 196 | readyToShowCamera = true 197 | } 198 | } 199 | .onDisappear { 200 | readyToShowCamera = false 201 | } 202 | if readyToShowCamera { 203 | cameraSheet() { 204 | // did take picture 205 | if !cameraSelectionService.hasSelected { 206 | viewModel.setPickerMode(.cameraSelection) 207 | } 208 | guard let url = viewModel.pickedMediaUrl else { return } 209 | cameraSelectionService.onSelect(media: URLMediaModel(url: url)) 210 | viewModel.pickedMediaUrl = nil 211 | } didPressCancel: { 212 | if let didPressCancel = didPressCancelCamera { 213 | didPressCancel() 214 | } else { 215 | viewModel.setPickerMode(.photos) 216 | } 217 | } 218 | .confirmationDialog("", isPresented: $viewModel.showingExitCameraConfirmation, titleVisibility: .hidden) { 219 | deleteAllButton 220 | } 221 | } 222 | } 223 | .onAppear { 224 | orientationHandler(.lock) 225 | } 226 | .onDisappear { 227 | orientationHandler(.unlock) 228 | } 229 | } 230 | 231 | var deleteAllButton: some View { 232 | Button("Delete All") { 233 | cameraSelectionService.removeAll() 234 | viewModel.setPickerMode(.photos) 235 | onChange(selectionService.mapToMedia()) 236 | } 237 | } 238 | 239 | var defaultHeaderView: some View { 240 | HStack { 241 | Button("Cancel") { 242 | selectionService.removeAll() 243 | cameraSelectionService.removeAll() 244 | isPresented = false 245 | } 246 | 247 | Spacer() 248 | 249 | Picker("", selection: 250 | Binding( 251 | get: { viewModel.internalPickerMode == .albums ? 1 : 0 }, 252 | set: { value in 253 | viewModel.setPickerMode(value == 0 ? .photos : .albums) 254 | } 255 | ) 256 | ) { 257 | Text("Photos") 258 | .tag(0) 259 | Text("Albums") 260 | .tag(1) 261 | } 262 | .pickerStyle(SegmentedPickerStyle()) 263 | .frame(maxWidth: UIScreen.main.bounds.width / 2) 264 | 265 | Spacer() 266 | 267 | Button("Done") { 268 | if selectionService.selected.isEmpty, let current = currentFullscreenMedia { 269 | onChange([current]) 270 | } 271 | isPresented = false 272 | } 273 | } 274 | .foregroundColor(theme.main.pickerText) 275 | .padding(12) 276 | .background(theme.defaultHeader.background) 277 | } 278 | 279 | func cameraBinding() -> Binding { 280 | Binding( 281 | get: { viewModel.internalPickerMode == .camera }, 282 | set: { value in 283 | if value { viewModel.setPickerMode(.camera) } 284 | } 285 | ) 286 | } 287 | 288 | func modeBinding() -> Binding { 289 | Binding( 290 | get: { viewModel.internalPickerMode == .albums ? 1 : 0 }, 291 | set: { value in 292 | viewModel.setPickerMode(value == 0 ? .photos : .albums) 293 | } 294 | ) 295 | } 296 | 297 | @ViewBuilder 298 | func cameraSheet(didTakePicture: @escaping ()->(), didPressCancel: @escaping ()->()) -> some View { 299 | #if targetEnvironment(simulator) 300 | CameraStubView { 301 | didPressCancel() 302 | } 303 | #elseif os(iOS) 304 | Group { 305 | if let cameraViewBuilder = cameraViewBuilder { 306 | CustomCameraView(viewModel: viewModel, didTakePicture: didTakePicture, didPressCancel: didPressCancel, cameraViewBuilder: cameraViewBuilder) 307 | .ignoresSafeArea() 308 | } else { 309 | StandardConrolsCameraView(viewModel: viewModel, didTakePicture: didTakePicture, didPressCancel: didPressCancel, selectionParamsHolder: selectionParamsHolder) 310 | .ignoresSafeArea() 311 | } 312 | } 313 | .onAppear { 314 | PermissionsService.shared.requestCameraPermission() 315 | } 316 | #endif 317 | } 318 | } 319 | 320 | // MARK: - Customization 321 | 322 | public extension MediaPicker { 323 | 324 | func showLiveCameraCell(_ show: Bool = true) -> MediaPicker { 325 | var mediaPicker = self 326 | mediaPicker.showingLiveCameraCell = show 327 | return mediaPicker 328 | } 329 | 330 | func mediaSelectionType(_ type: MediaSelectionType) -> MediaPicker { 331 | selectionParamsHolder.mediaType = type 332 | return self 333 | } 334 | 335 | func mediaSelectionStyle(_ style: MediaSelectionStyle) -> MediaPicker { 336 | selectionParamsHolder.selectionStyle = style 337 | return self 338 | } 339 | 340 | func mediaSelectionLimit(_ limit: Int) -> MediaPicker { 341 | selectionParamsHolder.selectionLimit = limit 342 | return self 343 | } 344 | 345 | func showFullscreenPreview(_ show: Bool) -> MediaPicker { 346 | selectionParamsHolder.showFullscreenPreview = show 347 | return self 348 | } 349 | 350 | func setSelectionParameters(_ params: SelectionParamsHolder?) -> MediaPicker { 351 | guard let params = params else { 352 | return self 353 | } 354 | var mediaPicker = self 355 | mediaPicker.selectionParamsHolder = params 356 | return mediaPicker 357 | } 358 | 359 | func applyFilter(_ filterClosure: @escaping FilterClosure) -> MediaPicker { 360 | var mediaPicker = self 361 | mediaPicker.filterClosure = filterClosure 362 | return mediaPicker 363 | } 364 | 365 | func applyFilter(_ filterClosure: @escaping MassFilterClosure) -> MediaPicker { 366 | var mediaPicker = self 367 | mediaPicker.massFilterClosure = filterClosure 368 | return mediaPicker 369 | } 370 | 371 | func didPressCancelCamera(_ didPressCancelCamera: @escaping ()->()) -> MediaPicker { 372 | var mediaPicker = self 373 | mediaPicker.didPressCancelCamera = didPressCancelCamera 374 | return mediaPicker 375 | } 376 | 377 | func orientationHandler(_ orientationHandler: @escaping MediaPickerOrientationHandler) -> MediaPicker { 378 | var mediaPicker = self 379 | mediaPicker.orientationHandler = orientationHandler 380 | return mediaPicker 381 | } 382 | 383 | func currentFullscreenMedia(_ currentFullscreenMedia: Binding) -> MediaPicker { 384 | var mediaPicker = self 385 | mediaPicker._currentFullscreenMediaBinding = currentFullscreenMedia 386 | return mediaPicker 387 | } 388 | 389 | func albums(_ albums: Binding<[Album]>) -> MediaPicker { 390 | var mediaPicker = self 391 | mediaPicker._albums = albums 392 | return mediaPicker 393 | } 394 | 395 | func pickerMode(_ mode: Binding) -> MediaPicker { 396 | var mediaPicker = self 397 | mediaPicker.pickerMode = mode 398 | return mediaPicker 399 | } 400 | } 401 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/MediaPicker/MediaPickerMode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 07.06.2022. 3 | // 4 | 5 | import Foundation 6 | 7 | public enum MediaPickerMode: Equatable { 8 | 9 | case photos 10 | case albums 11 | case album(Album) 12 | case camera 13 | case cameraSelection 14 | 15 | public static func == (lhs: MediaPickerMode, rhs: MediaPickerMode) -> Bool { 16 | switch (lhs, rhs) { 17 | case (.photos, .photos): 18 | return true 19 | case (.albums, .albums): 20 | return true 21 | case (.album(let a1), .album(let a2)): 22 | return a1.id == a2.id 23 | case (.camera, .camera): 24 | return true 25 | case (.cameraSelection, .cameraSelection): 26 | return true 27 | default: 28 | return false 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Screens/MediaPicker/MediaPickerViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 07.06.2022. 3 | // 4 | 5 | import Foundation 6 | import SwiftUI 7 | 8 | @MainActor 9 | final class MediaPickerViewModel: ObservableObject { 10 | 11 | #if os(iOS) 12 | @Published var showingExitCameraConfirmation = false 13 | @Published var pickedMediaUrl: URL? 14 | #endif 15 | 16 | @Published private(set) var defaultAlbumsProvider = DefaultAlbumsProvider() 17 | @Published private(set) var internalPickerMode: MediaPickerMode = .photos 18 | 19 | var albums: [AlbumModel] { 20 | defaultAlbumsProvider.albums 21 | } 22 | 23 | var shouldUpdatePickerMode: (MediaPickerMode)->() = {_ in} 24 | 25 | func onStart() { 26 | defaultAlbumsProvider.reload() 27 | } 28 | 29 | func getAlbumModel(_ album: Album) -> AlbumModel? { 30 | albums.filter { $0.id == album.id }.first 31 | } 32 | 33 | func setPickerMode(_ mode: MediaPickerMode) { 34 | internalPickerMode = mode 35 | shouldUpdatePickerMode(mode) 36 | } 37 | 38 | func onCancelCameraSelection(_ hasSelected: Bool) { 39 | if hasSelected { 40 | showingExitCameraConfirmation = true 41 | } else { 42 | setPickerMode(.camera) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Theme/Bundle+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bundle+.swift 3 | // 4 | // 5 | // Created by Alex.M on 07.07.2022. 6 | // 7 | 8 | import Foundation 9 | 10 | private final class BundleToken { 11 | static let bundle: Bundle = { 12 | #if SWIFT_PACKAGE 13 | return Bundle.module 14 | #else 15 | return Bundle(for: BundleToken.self) 16 | #endif 17 | }() 18 | 19 | private init() {} 20 | } 21 | 22 | public extension Bundle { 23 | static var current: Bundle { 24 | BundleToken.bundle 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Theme/MediaPickerTheme.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 06.07.2022. 3 | // 4 | 5 | import Foundation 6 | import SwiftUI 7 | 8 | public struct MediaPickerTheme: Sendable { 9 | public let main: Main 10 | public let selection: Selection 11 | public let cellStyle: CellStyle 12 | public let error: Error 13 | public let defaultHeader: DefaultHeader 14 | 15 | public init(main: MediaPickerTheme.Main = .init(), 16 | selection: MediaPickerTheme.Selection = .init(), 17 | cellStyle: MediaPickerTheme.CellStyle = .init(), 18 | error: MediaPickerTheme.Error = .init(), 19 | defaultHeader: MediaPickerTheme.DefaultHeader = .init()) { 20 | self.main = main 21 | self.selection = selection 22 | self.cellStyle = cellStyle 23 | self.error = error 24 | self.defaultHeader = defaultHeader 25 | } 26 | } 27 | 28 | extension MediaPickerTheme { 29 | public struct Main: Sendable { 30 | public let pickerText: Color 31 | public let pickerBackground: Color 32 | public let fullscreenPhotoBackground: Color 33 | public let cameraText: Color 34 | public let cameraBackground: Color 35 | public let cameraSelectionText: Color 36 | public let cameraSelectionBackground: Color 37 | 38 | public init( 39 | pickerText: Color = Color("pickerText", bundle: .current), 40 | pickerBackground: Color = Color("pickerBG", bundle: .current), 41 | fullscreenPhotoBackground: Color = Color("pickerBG", bundle: .current), 42 | cameraText: Color = Color("cameraText", bundle: .current), 43 | cameraBackground: Color = Color("cameraBG", bundle: .current), 44 | cameraSelectionText: Color = Color("cameraText", bundle: .current), 45 | cameraSelectionBackground: Color = Color("cameraBG", bundle: .current) 46 | ) { 47 | self.pickerText = pickerText 48 | self.pickerBackground = pickerBackground 49 | self.fullscreenPhotoBackground = fullscreenPhotoBackground 50 | self.cameraText = cameraText 51 | self.cameraBackground = cameraBackground 52 | self.cameraSelectionText = cameraSelectionText 53 | self.cameraSelectionBackground = cameraSelectionBackground 54 | } 55 | } 56 | 57 | public struct Selection: Sendable { 58 | public let cellEmptyBorder: Color 59 | public let cellEmptyBackground: Color 60 | public let cellSelectedBorder: Color 61 | public let cellSelectedBackground: Color 62 | public let cellSelectedCheckmark: Color 63 | public let fullscreenEmptyBorder: Color 64 | public let fullscreenEmptyBackground: Color 65 | public let fullscreenSelectedBorder: Color 66 | public let fullscreenSelectedBackground: Color 67 | public let fullscreenSelectedCheckmark: Color 68 | 69 | public init( 70 | cellEmptyBorder: Color = .white, 71 | cellEmptyBackground: Color = .black.opacity(0.25), 72 | cellSelectedBorder: Color = .white, 73 | cellSelectedBackground: Color = Color("selection", bundle: .current), 74 | cellSelectedCheckmark: Color = .white, 75 | fullscreenEmptyBorder: Color = Color("selection", bundle: .current), 76 | fullscreenEmptyBackground: Color = .clear, 77 | fullscreenSelectedBorder: Color = Color("selection", bundle: .current), 78 | fullscreenSelectedBackground: Color = Color("selection", bundle: .current), 79 | fullscreenSelectedCheckmark: Color = .white 80 | ) { 81 | self.cellEmptyBorder = cellEmptyBorder 82 | self.cellEmptyBackground = cellEmptyBackground 83 | self.cellSelectedBorder = cellSelectedBorder 84 | self.cellSelectedBackground = cellSelectedBackground 85 | self.cellSelectedCheckmark = cellSelectedCheckmark 86 | self.fullscreenEmptyBorder = fullscreenEmptyBorder 87 | self.fullscreenEmptyBackground = fullscreenEmptyBackground 88 | self.fullscreenSelectedBorder = fullscreenSelectedBorder 89 | self.fullscreenSelectedBackground = fullscreenSelectedBackground 90 | self.fullscreenSelectedCheckmark = fullscreenSelectedCheckmark 91 | } 92 | 93 | public init( 94 | accent: Color, 95 | tint: Color = .white, 96 | background: Color = .black.opacity(0.25) 97 | ) { 98 | self.init( 99 | cellEmptyBorder: tint, 100 | cellEmptyBackground: background, 101 | cellSelectedBorder: tint, 102 | cellSelectedBackground: accent, 103 | cellSelectedCheckmark: tint, 104 | fullscreenEmptyBorder: accent, 105 | fullscreenEmptyBackground: .clear, 106 | fullscreenSelectedBorder: accent, 107 | fullscreenSelectedBackground: accent, 108 | fullscreenSelectedCheckmark: tint 109 | ) 110 | } 111 | } 112 | 113 | public struct CellStyle: Sendable { 114 | public let columnsSpacing: CGFloat 115 | public let rowSpacing: CGFloat 116 | public let cornerRadius: CGFloat 117 | 118 | public init(columnsSpacing: CGFloat = 1, 119 | rowSpacing: CGFloat = 1, 120 | cornerRadius: CGFloat = 0) { 121 | self.columnsSpacing = columnsSpacing 122 | self.rowSpacing = rowSpacing 123 | self.cornerRadius = cornerRadius 124 | } 125 | } 126 | 127 | public struct Error: Sendable { 128 | public let background: Color 129 | public let tint: Color 130 | 131 | public init(background: Color = .red.opacity(0.7), 132 | tint: Color = Color("cameraText", bundle: .current)) { 133 | self.background = background 134 | self.tint = tint 135 | } 136 | } 137 | 138 | public struct DefaultHeader: Sendable { 139 | public let background: Color 140 | 141 | public init(background: Color = Color("pickerBG", bundle: .current), 142 | segmentTintColor: Color = Color("pickerBG", bundle: .current), 143 | selectedSegmentTintColor: Color = Color("pickerBG", bundle: .current), 144 | selectedText: Color = Color("pickerText", bundle: .current), 145 | unselectedText: Color = Color("pickerText", bundle: .current)) { 146 | self.background = background 147 | 148 | DispatchQueue.main.async { 149 | UISegmentedControl.appearance().backgroundColor = UIColor(segmentTintColor) 150 | UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(selectedSegmentTintColor) 151 | UISegmentedControl.appearance().setTitleTextAttributes([.foregroundColor: UIColor(selectedText)], for: .selected) 152 | UISegmentedControl.appearance().setTitleTextAttributes([.foregroundColor: UIColor(unselectedText)], for: .normal) 153 | } 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Errors/PermissionActionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 06.06.2022. 3 | // 4 | 5 | import Foundation 6 | import SwiftUI 7 | 8 | struct PermissionActionView: View { 9 | 10 | enum PermissionType { 11 | case library(PermissionsService.PhotoLibraryPermissionStatus) 12 | case camera(PermissionsService.CameraPermissionStatus) 13 | } 14 | 15 | let type: PermissionType 16 | 17 | @State private var showSheet = false 18 | 19 | var body: some View { 20 | ZStack { 21 | if showSheet { 22 | LimitedLibraryPickerProxyView(isPresented: $showSheet) { 23 | DispatchQueue.main.async { 24 | PermissionsService.shared.updatePhotoLibraryAuthorizationStatus() 25 | } 26 | } 27 | .frame(width: 1, height: 1) 28 | } 29 | 30 | switch type { 31 | case .library(let status): 32 | buildLibraryActionView(status) 33 | case .camera(let status): 34 | buildCameraActionView(status) 35 | } 36 | } 37 | } 38 | } 39 | 40 | private extension PermissionActionView { 41 | 42 | @ViewBuilder 43 | func buildLibraryActionView(_ status: PermissionsService.PhotoLibraryPermissionStatus) -> some View { 44 | switch status { 45 | case .authorized, .unknown: 46 | EmptyView() 47 | case .limited: 48 | PermissionsErrorView(text: "Setup Photos access to see more photos here") { 49 | showSheet = true 50 | } 51 | case .unavailable: 52 | goToSettingsButton(text: "Allow Photos access in settings to see photos here") 53 | } 54 | } 55 | 56 | @ViewBuilder 57 | func buildCameraActionView(_ status: PermissionsService.CameraPermissionStatus) -> some View { 58 | switch status { 59 | case .authorized, .unknown: 60 | EmptyView() 61 | case .unavailable: 62 | goToSettingsButton(text: "Allow Camera access in settings to see live preview") 63 | } 64 | } 65 | 66 | func goToSettingsButton(text: String) -> some View { 67 | PermissionsErrorView( 68 | text: text, 69 | action: { 70 | DispatchQueue.main.async { 71 | guard let url = URL(string: UIApplication.openSettingsURLString) else { return } 72 | if UIApplication.shared.canOpenURL(url) { 73 | UIApplication.shared.open(url) 74 | } 75 | } 76 | } 77 | ) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Errors/PermissionsErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 06.06.2022. 3 | // 4 | 5 | import Foundation 6 | import SwiftUI 7 | 8 | struct PermissionsErrorView: View { 9 | 10 | let text: String 11 | let action: (() -> Void)? 12 | 13 | @Environment(\.mediaPickerTheme) private var theme 14 | 15 | var body: some View { 16 | Group { 17 | if let action = action { 18 | Button { 19 | action() 20 | } label: { 21 | Text(text) 22 | } 23 | } else { 24 | Text(text) 25 | } 26 | } 27 | .frame(maxWidth: .infinity) 28 | .padding() 29 | .foregroundColor(theme.error.tint) 30 | .background(theme.error.background) 31 | .cornerRadius(5) 32 | .padding(.horizontal, 20) 33 | .padding(.bottom, 6) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Extensions/Collection+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // ExyteMediaPicker 4 | // 5 | // Created by Alisa Mylnikova on 25.02.2025. 6 | // 7 | 8 | extension Collection { 9 | subscript(safe index: Index) -> Element? { 10 | indices.contains(index) ? self[index] : nil 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Extensions/ColumnCalculation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Untitled.swift 3 | // ExyteMediaPicker 4 | // 5 | // Created by Alisa Mylnikova on 25.03.2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @MainActor 11 | func calculateColumnWidth(spacing: CGFloat) -> (CGFloat, [GridItem]) { 12 | let gridWidth = UIScreen.main.bounds.width 13 | let minColumnWidth = 100.0 14 | let wholeCount = CGFloat(Int(gridWidth / minColumnWidth)) 15 | let noSpaces = gridWidth - spacing * (wholeCount - 1) 16 | let columnWidth = noSpaces / wholeCount 17 | let columns = Array(repeating: GridItem(.fixed(columnWidth), spacing: spacing, alignment: .top), count: Int(wholeCount)) 18 | return (columnWidth, columns) 19 | } 20 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Extensions/OrientationTransformationExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OrientationTransformationExtensions.swift 3 | // 4 | // 5 | // Created by Alexandra Afonasova on 18.10.2022. 6 | // 7 | 8 | import UIKit 9 | import AVFoundation 10 | 11 | extension UIImage.Orientation { 12 | 13 | init(_ deviceOrientation: UIDeviceOrientation) { 14 | switch deviceOrientation { 15 | case .landscapeLeft: self = .up 16 | case .landscapeRight: self = .down 17 | case .portraitUpsideDown: self = .left 18 | default: self = .right 19 | } 20 | } 21 | 22 | static var `default`: UIImage.Orientation { .right } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Extensions/Sequence+asyncMap.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Alisa Mylnikova on 17.05.2023. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Sequence { 11 | func asyncMap( 12 | _ transform: (Element) async throws -> T 13 | ) async rethrows -> [T] { 14 | var values = [T]() 15 | 16 | for element in self { 17 | try await values.append(transform(element)) 18 | } 19 | 20 | return values 21 | } 22 | } 23 | 24 | extension Sequence { 25 | func asyncForEach( 26 | _ operation: (Element) async throws -> Void 27 | ) async rethrows { 28 | for element in self { 29 | try await operation(element) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Extensions/TimeInterval+Duration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 31.05.2022. 3 | // 4 | 5 | import Foundation 6 | 7 | extension TimeInterval { 8 | func formatted(locale: Locale = .current) -> String? { 9 | let formatter = DateComponentsFormatter() 10 | formatter.unitsStyle = .abbreviated 11 | formatter.zeroFormattingBehavior = .dropAll 12 | formatter.allowedUnits = [.day, .hour, .minute, .second] 13 | formatter.maximumUnitCount = 2 14 | 15 | formatter.calendar = Calendar.current 16 | formatter.calendar?.locale = locale 17 | 18 | return formatter.string(from: self) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Extensions/View+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Alisa Mylnikova on 04.09.2023. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension View { 11 | func dismissKeyboard() { 12 | DispatchQueue.main.async { 13 | UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) 14 | } 15 | } 16 | } 17 | 18 | extension Shape { 19 | func styled(_ foregroundColor: Color, border borderColor: Color = .clear, _ borderWidth: CGFloat = 0) -> some View { 20 | self.foregroundStyle(foregroundColor) // Apply foreground color 21 | .overlay { 22 | self 23 | .stroke(borderColor, lineWidth: borderWidth) // Apply border color and width 24 | } 25 | } 26 | } 27 | 28 | extension View { 29 | func padding(_ horizontal: CGFloat, _ vertical: CGFloat) -> some View { 30 | self.padding(.horizontal, horizontal) 31 | .padding(.vertical, vertical) 32 | } 33 | 34 | @ViewBuilder 35 | func applyIf(_ condition: Bool, apply: (Self) -> T) -> some View { 36 | if condition { 37 | apply(self) 38 | } else { 39 | self 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Extensions/View+NotificationCenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+NotificationCenter.swift 3 | // 4 | // 5 | // Created by Alexandra Afonasova on 17.10.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension View { 11 | @MainActor func onRotate(perform: @escaping (UIDeviceOrientation) -> Void) -> some View { 12 | let publisher = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification) 13 | return onReceive(publisher) { _ in perform(UIDevice.current.orientation) } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Extensions/Zoom+ScrollView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Zoom+ScrollView.swift 3 | // 4 | // 5 | // Created by Ruslan on 19.07.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ZoomableScrollView: UIViewRepresentable { 11 | private var content: Content 12 | 13 | init(@ViewBuilder content: () -> Content) { 14 | self.content = content() 15 | } 16 | 17 | func makeUIView(context: Context) -> UIScrollView { 18 | 19 | let scrollView = UIScrollView() 20 | scrollView.delegate = context.coordinator 21 | scrollView.maximumZoomScale = 10 22 | scrollView.minimumZoomScale = 1 23 | scrollView.bouncesZoom = true 24 | 25 | let hostedView = context.coordinator.hostingController.view! 26 | hostedView.translatesAutoresizingMaskIntoConstraints = true 27 | hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight] 28 | hostedView.frame = scrollView.bounds 29 | scrollView.addSubview(hostedView) 30 | scrollView.showsVerticalScrollIndicator = false 31 | scrollView.showsHorizontalScrollIndicator = false 32 | 33 | return scrollView 34 | } 35 | 36 | func makeCoordinator() -> Coordinator { 37 | return Coordinator(hostingController: UIHostingController(rootView: self.content)) 38 | } 39 | 40 | func updateUIView(_ uiView: UIScrollView, context: Context) { 41 | context.coordinator.hostingController.rootView = self.content 42 | assert(context.coordinator.hostingController.view.superview == uiView) 43 | } 44 | 45 | class Coordinator: NSObject, UIScrollViewDelegate { 46 | var hostingController: UIHostingController 47 | 48 | init(hostingController: UIHostingController) { 49 | self.hostingController = hostingController 50 | self.hostingController.view.backgroundColor = UIColor.clear 51 | } 52 | 53 | func viewForZooming(in scrollView: UIScrollView) -> UIView? { 54 | return hostingController.view 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Modifiers/KeyboardHeightHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardHeightHelper.swift 3 | // Example-iOS 4 | // 5 | // Created by Alisa Mylnikova on 23.08.2023. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if compiler(>=6.0) 11 | extension Notification: @retroactive @unchecked Sendable { } 12 | #else 13 | extension Notification: @unchecked Sendable { } 14 | #endif 15 | 16 | @MainActor 17 | class KeyboardHeightHelper: ObservableObject { 18 | 19 | static let shared = KeyboardHeightHelper() 20 | 21 | @Published var keyboardHeight: CGFloat = 0 22 | @Published var keyboardDisplayed: Bool = false 23 | 24 | init() { 25 | self.listenForKeyboardNotifications() 26 | } 27 | 28 | private func listenForKeyboardNotifications() { 29 | NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { [weak self] notification in 30 | guard let self = self else { return } 31 | 32 | Task { @MainActor in 33 | // Safely extract the data from the notification on the main actor 34 | guard let userInfo = notification.userInfo, 35 | let keyboardRect = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } 36 | 37 | // Now update the UI on the main actor 38 | self.keyboardHeight = keyboardRect.height 39 | self.keyboardDisplayed = true 40 | } 41 | } 42 | 43 | NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { (notification) in 44 | DispatchQueue.main.async { 45 | self.keyboardHeight = 0 46 | } 47 | } 48 | 49 | NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidHideNotification, object: nil, queue: .main) { (notification) in 50 | DispatchQueue.main.async { 51 | self.keyboardDisplayed = false 52 | } 53 | } 54 | } 55 | 56 | private func handleKeyboardWillShow(_ notification: Notification) { 57 | guard let userInfo = notification.userInfo, 58 | let keyboardRect = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { 59 | return 60 | } 61 | self.keyboardHeight = keyboardRect.height 62 | self.keyboardDisplayed = true 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Modifiers/MediaButtonStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 06.07.2022. 3 | // 4 | 5 | import Foundation 6 | import SwiftUI 7 | 8 | struct MediaButtonStyle: ButtonStyle { 9 | func makeBody(configuration: Self.Configuration) -> some View { 10 | configuration 11 | .label 12 | .opacity(configuration.isPressed ? 0.7 : 1.0) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Modifiers/MediaPickerThemeModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 06.07.2022. 3 | // 4 | 5 | import Foundation 6 | import SwiftUI 7 | 8 | public extension EnvironmentValues { 9 | #if swift(>=6.0) 10 | @Entry var mediaPickerTheme = MediaPickerTheme() 11 | @Entry var mediaPickerThemeIsOverridden = false 12 | #else 13 | var mediaPickerTheme: MediaPickerTheme { 14 | get { self[MediaPickerThemeKey.self] } 15 | set { self[MediaPickerThemeKey.self] = newValue } 16 | } 17 | 18 | var mediaPickerThemeIsOverridden: Bool { 19 | get { self[MediaPickerThemeIsOverriddenKey.self] } 20 | set { self[MediaPickerThemeIsOverriddenKey.self] = newValue } 21 | } 22 | #endif 23 | } 24 | 25 | // Define keys only for older versions 26 | #if swift(<6.0) 27 | @preconcurrency public struct MediaPickerThemeKey: EnvironmentKey { 28 | public static let defaultValue = MediaPickerTheme() 29 | } 30 | 31 | public struct MediaPickerThemeIsOverriddenKey: EnvironmentKey { 32 | public static let defaultValue = false 33 | } 34 | #endif 35 | 36 | public extension View { 37 | func mediaPickerTheme(_ theme: MediaPickerTheme) -> some View { 38 | self.environment(\.mediaPickerTheme, theme) 39 | .environment(\.mediaPickerThemeIsOverridden, true) 40 | } 41 | 42 | func mediaPickerTheme( 43 | main: MediaPickerTheme.Main = .init(), 44 | selection: MediaPickerTheme.Selection = .init(), 45 | cellStyle: MediaPickerTheme.CellStyle = .init(), 46 | error: MediaPickerTheme.Error = .init(), 47 | defaultHeader: MediaPickerTheme.DefaultHeader = .init() 48 | ) -> some View { 49 | self.environment(\.mediaPickerTheme, MediaPickerTheme(main: main, selection: selection, cellStyle: cellStyle, error: error, defaultHeader: defaultHeader)) 50 | .environment(\.mediaPickerThemeIsOverridden, true) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Modifiers/SafeAreaEnvironmentValues.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SafeAreaEnvironmentValues.swift 3 | // 4 | // 5 | // Created by Alexandra Afonasova on 18.10.2022. 6 | // 7 | 8 | import UIKit 9 | import SwiftUI 10 | 11 | extension UIApplication { 12 | var keyWindow: UIWindow? { 13 | connectedScenes 14 | .compactMap { 15 | $0 as? UIWindowScene 16 | } 17 | .flatMap { 18 | $0.windows 19 | } 20 | .first { 21 | $0.isKeyWindow 22 | } 23 | } 24 | 25 | static var safeArea: EdgeInsets { 26 | UIApplication.shared.keyWindow?.safeAreaInsets.swiftUiInsets ?? EdgeInsets() 27 | } 28 | } 29 | 30 | private extension UIEdgeInsets { 31 | var swiftUiInsets: EdgeInsets { 32 | EdgeInsets(top: top, leading: left, bottom: bottom, trailing: right) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Thumbnail/ThumbnailPlaceholder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 30.05.2022. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct ThumbnailPlaceholder: View { 8 | 9 | var body: some View { 10 | Rectangle() 11 | .fill(.gray.opacity(0.3)) 12 | .aspectRatio(1, contentMode: .fill) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Thumbnail/ThumbnailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 31.05.2022. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct ThumbnailView: View { 8 | 9 | #if os(iOS) 10 | let preview: UIImage? 11 | let size: CGFloat 12 | #else 13 | // FIXME: Create preview for image/video for other platforms 14 | #endif 15 | 16 | var body: some View { 17 | if let preview = preview { 18 | Image(uiImage: preview) 19 | .resizable() 20 | .scaledToFill() 21 | .frame(width: size, height: size) 22 | .clipped() 23 | } else { 24 | ThumbnailPlaceholder() 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Widgets/AsyncButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by Alisa Mylnikova on 01.04.2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AsyncButton: View { 11 | var action: () async -> () 12 | var label: (()->Content) 13 | 14 | var body: some View { 15 | Button { 16 | Task { 17 | await action() 18 | } 19 | } label: { 20 | label() 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Widgets/CameraStubView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 31.05.2022. 3 | // 4 | 5 | #if targetEnvironment(simulator) 6 | import SwiftUI 7 | 8 | struct CameraStubView: View { 9 | 10 | let didPressCancel: () -> Void 11 | 12 | var body: some View { 13 | ZStack { 14 | RoundedRectangle(cornerRadius: 20) 15 | .fill(.white) 16 | .ignoresSafeArea() 17 | 18 | VStack { 19 | Text("Camera") 20 | .font(.largeTitle) 21 | Text("Unavailable on simulator. Use device for testing") 22 | .font(.title3) 23 | .multilineTextAlignment(.center) 24 | Button("Close") { 25 | didPressCancel() 26 | } 27 | .padding() 28 | } 29 | .foregroundStyle(.black) 30 | } 31 | } 32 | } 33 | 34 | struct CameraStubView_Preview: PreviewProvider { 35 | static var previews: some View { 36 | CameraStubView { 37 | debugPrint("close") 38 | } 39 | } 40 | } 41 | 42 | #endif 43 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Widgets/LimitedLibraryPickerProxyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 02.06.2022. 3 | // 4 | 5 | import SwiftUI 6 | import UIKit 7 | import PhotosUI 8 | 9 | @MainActor 10 | struct LimitedLibraryPickerProxyView: UIViewControllerRepresentable { 11 | @Binding var isPresented: Bool 12 | var didDismiss: @Sendable ()->() 13 | 14 | func makeUIViewController(context: Context) -> UIViewController { 15 | let controller = UIViewController() 16 | 17 | DispatchQueue.main.async { [controller] in 18 | PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: controller) 19 | trackCompletion(in: controller) 20 | } 21 | 22 | return controller 23 | } 24 | 25 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} 26 | 27 | func trackCompletion(in controller: UIViewController) { 28 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak controller] in 29 | if controller?.presentedViewController == nil { 30 | self.$isPresented.wrappedValue = false 31 | self.didDismiss() 32 | } else if let controller = controller { 33 | self.trackCompletion(in: controller) 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Widgets/MediasGrid.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 06.06.2022. 3 | // 4 | 5 | import Foundation 6 | import SwiftUI 7 | 8 | public struct MediasGrid: View 9 | where Element: Identifiable, Camera: View, Content: View, LoadingCell: View { 10 | 11 | public let data: [Element] 12 | public let camera: () -> Camera 13 | public let content: (Element, _ index: Int, _ size: CGFloat) -> Content 14 | public let loadingCell: () -> LoadingCell 15 | 16 | @Environment(\.mediaPickerTheme) private var theme 17 | 18 | public init(_ data: [Element], 19 | @ViewBuilder camera: @escaping () -> Camera, 20 | @ViewBuilder content: @escaping (Element, Int, CGFloat) -> Content, 21 | @ViewBuilder loadingCell: @escaping () -> LoadingCell) { 22 | self.data = data 23 | self.camera = camera 24 | self.content = content 25 | self.loadingCell = loadingCell 26 | } 27 | 28 | public var body: some View { 29 | let (columnWidth, columns) = calculateColumnWidth(spacing: theme.cellStyle.columnsSpacing) 30 | LazyVGrid(columns: columns, spacing: theme.cellStyle.rowSpacing) { 31 | camera() 32 | ForEach(data.indices, id: \.self) { index in 33 | content(data[index], index, columnWidth) 34 | } 35 | loadingCell() 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Widgets/PlayerUIView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Alisa Mylnikova on 04.09.2023. 6 | // 7 | 8 | import SwiftUI 9 | import AVFoundation 10 | 11 | struct PlayerView: UIViewRepresentable { 12 | 13 | var player: AVPlayer 14 | var bgColor: Color 15 | var useFill: Bool 16 | 17 | func makeUIView(context: Context) -> PlayerUIView { 18 | PlayerUIView(player: player, bgColor: bgColor, useFill: useFill) 19 | } 20 | 21 | func updateUIView(_ uiView: PlayerUIView, context: UIViewRepresentableContext) { 22 | uiView.playerLayer.player = player 23 | uiView.playerLayer.videoGravity = useFill ? .resizeAspectFill : .resizeAspect 24 | } 25 | } 26 | 27 | class PlayerUIView: UIView { 28 | 29 | // MARK: Class Property 30 | 31 | let playerLayer = AVPlayerLayer() 32 | 33 | // MARK: Init 34 | 35 | override init(frame: CGRect) { 36 | super.init(frame: frame) 37 | } 38 | 39 | required init?(coder: NSCoder) { 40 | fatalError("init(coder:) has not been implemented") 41 | } 42 | 43 | init(player: AVPlayer, bgColor: Color, useFill: Bool) { 44 | super.init(frame: .zero) 45 | self.playerSetup(player: player, bgColor: bgColor, useFill: useFill) 46 | } 47 | 48 | deinit { 49 | NotificationCenter.default.removeObserver(self) 50 | } 51 | 52 | // MARK: Life-Cycle 53 | 54 | override func layoutSubviews() { 55 | super.layoutSubviews() 56 | playerLayer.frame = bounds 57 | } 58 | 59 | // MARK: Class Methods 60 | 61 | private func playerSetup(player: AVPlayer, bgColor: Color, useFill: Bool) { 62 | playerLayer.player = player 63 | playerLayer.videoGravity = useFill ? .resizeAspectFill : .resizeAspect 64 | player.actionAtItemEnd = .none 65 | layer.addSublayer(playerLayer) 66 | playerLayer.backgroundColor = bgColor.cgColor 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Widgets/SelectableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 27.05.2022. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct SelectableView: View where Content: View { 8 | 9 | var selected: Int? 10 | var isFullscreen: Bool 11 | var canSelect: Bool 12 | var selectionParamsHolder: SelectionParamsHolder 13 | var onSelect: () -> Void 14 | @ViewBuilder var content: () -> Content 15 | 16 | var body: some View { 17 | content() 18 | .overlay(alignment: .topTrailing) { 19 | SelectionIndicatorView(index: selected, isFullscreen: isFullscreen, canSelect: canSelect, selectionParamsHolder: selectionParamsHolder) 20 | .padding([.bottom, .leading], 10) // extend tappable area where possible 21 | .contentShape(Rectangle()) 22 | .onTapGesture { 23 | onSelect() 24 | } 25 | .padding(2) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/ExyteMediaPicker/Utils/Widgets/SelectionIndicatorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 27.05.2022. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct SelectionIndicatorView: View { 8 | 9 | @EnvironmentObject private var selectionService: SelectionService 10 | 11 | @Environment(\.mediaPickerTheme) var theme 12 | 13 | var index: Int? 14 | var isFullscreen: Bool 15 | var canSelect: Bool 16 | var selectionParamsHolder: SelectionParamsHolder 17 | 18 | var size: CGFloat { isFullscreen ? 26 : 24 } 19 | 20 | var emptyBorder: Color { isFullscreen ? theme.selection.fullscreenEmptyBorder : theme.selection.cellEmptyBorder } 21 | var emptyBackground: Color { isFullscreen ? theme.selection.fullscreenEmptyBackground : theme.selection.cellEmptyBackground } 22 | var selectedBorder: Color { isFullscreen ? theme.selection.fullscreenSelectedBorder : theme.selection.cellSelectedBorder } 23 | var selectedBackground: Color { isFullscreen ? theme.selection.fullscreenSelectedBackground : theme.selection.cellSelectedBackground } 24 | var selectedCheckmark: Color { isFullscreen ? theme.selection.fullscreenSelectedCheckmark : theme.selection.cellSelectedCheckmark } 25 | 26 | var body: some View { 27 | Group { 28 | switch selectionParamsHolder.selectionStyle { 29 | case .checkmark: 30 | checkView 31 | case .count: 32 | countView 33 | } 34 | } 35 | .frame(width: size, height: size) 36 | } 37 | 38 | @ViewBuilder 39 | var checkView: some View { 40 | if canSelect { 41 | let selected = index != nil 42 | ZStack { 43 | Circle().styled( 44 | selected ? selectedBackground : emptyBackground, 45 | border: selected ? selectedBorder : emptyBorder, 2 46 | ) 47 | if index != nil { 48 | Image(systemName: "checkmark") 49 | .resizable() 50 | .foregroundColor(selectedCheckmark) 51 | .font(.system(size: 14, weight: .bold)) 52 | .padding(7) 53 | } 54 | } 55 | .animation(.easeOut(duration: 0.2), value: selected) 56 | } 57 | } 58 | 59 | @ViewBuilder 60 | var countView: some View { 61 | if canSelect { 62 | let selected = index != nil 63 | ZStack { 64 | Circle().styled( 65 | selected ? selectedBackground : emptyBackground, 66 | border: selected ? selectedBorder : emptyBorder, 2 67 | ) 68 | if let index { 69 | Text("\(index + 1)") 70 | .foregroundColor(selectedCheckmark) 71 | .font(.system(size: 14, weight: .bold)) 72 | } 73 | } 74 | .animation(.easeOut(duration: 0.2), value: selected) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Tests/MediaPickerTests/Extensions/TimeInterval+Init.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 31.05.2022. 3 | // 4 | 5 | import Foundation 6 | 7 | extension TimeInterval { 8 | init(days: Double = 0, hours: Double = 0, minutes: Double = 0, seconds: Double = 0) { 9 | self = days * 60 * 60 * 24 + hours * 60 * 60 + minutes * 60 + seconds 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Tests/MediaPickerTests/Tests/Extensions/TimeIntervalTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alex.M on 31.05.2022. 3 | // 4 | 5 | import XCTest 6 | @testable import ExyteMediaPicker 7 | 8 | final class TimeIntervalTests: XCTestCase { 9 | let testLocale: Locale = Locale(identifier: "en_US") 10 | 11 | func testReadableFewSeconds() throws { 12 | let interval = TimeInterval(seconds: 3) 13 | let readable = interval.formatted(locale: testLocale) 14 | 15 | XCTAssertNotNil(readable) 16 | XCTAssertEqual(readable, "3s") 17 | } 18 | 19 | func testReadableHalfMinute() throws { 20 | let interval = TimeInterval(seconds: 30) 21 | let readable = interval.formatted(locale: testLocale) 22 | 23 | XCTAssertNotNil(readable) 24 | XCTAssertEqual(readable, "30s") 25 | } 26 | 27 | func testReadableFewMinutes() throws { 28 | let interval = TimeInterval(minutes: 8, seconds: 44) 29 | let readable = interval.formatted(locale: testLocale) 30 | 31 | XCTAssertNotNil(readable) 32 | XCTAssertEqual(readable, "8m 44s") 33 | } 34 | 35 | func testReadableFewHoursRoundHalfMinuteWithoutOneToDown() throws { 36 | let interval = TimeInterval(hours: 3, minutes: 44, seconds: 29) 37 | let readable = interval.formatted(locale: testLocale) 38 | 39 | XCTAssertNotNil(readable) 40 | XCTAssertEqual(readable, "3h 44m") 41 | } 42 | 43 | func testReadableFewHoursRoundHalfMinuteToUp() throws { 44 | let interval = TimeInterval(hours: 3, minutes: 44, seconds: 30) 45 | let readable = interval.formatted(locale: testLocale) 46 | 47 | XCTAssertNotNil(readable) 48 | XCTAssertEqual(readable, "3h 45m") 49 | } 50 | } 51 | --------------------------------------------------------------------------------