├── .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 | 
13 | [](https://swiftpackageindex.com/exyte/MediaPicker)
14 | [](https://swiftpackageindex.com/exyte/MediaPicker)
15 | [](https://swiftpackageindex.com/exyte/MediaPicker)
16 | [](https://cocoapods.org/pods/ExyteMediaPicker)
17 | [](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 |
--------------------------------------------------------------------------------