diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..51de011 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,3 @@ +[*.{swift}] +indent_style = space +indent_size = 4 \ No newline at end of file diff --git a/Examples/Example-iOS/AppDelegate.swift b/Examples/Example-iOS/AppDelegate.swift deleted file mode 100644 index 4b66d7a..0000000 --- a/Examples/Example-iOS/AppDelegate.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// AppDelegate.swift -// Example-iOS -// -// Created by Alexandra Afonasova on 20.10.2022. -// - -import Foundation -import UIKit - -final class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject { - - private var orientationLock = UIInterfaceOrientationMask.all - - func lockOrientationToPortrait() { - if #available(iOS 16, *) { - if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene { - scene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait)) - } - UIViewController.attemptRotationToDeviceOrientation() - } else { - UIDevice.current.setValue(UIDeviceOrientation.portrait.rawValue, forKey: "orientation") - } - orientationLock = .portrait - } - - func unlockOrientation() { - orientationLock = .all - UIViewController.attemptRotationToDeviceOrientation() - } - - func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { - return orientationLock - } - -} diff --git a/Examples/Example-iOS/Info.plist b/Examples/Example-iOS/Info.plist deleted file mode 100644 index f9ff86c..0000000 --- a/Examples/Example-iOS/Info.plist +++ /dev/null @@ -1,12 +0,0 @@ - - - - - NSPhotoLibraryUsageDescription - Grant access to photo library to be able to select photos - NSMicrophoneUsageDescription - Grant access to microphone to be able to take videos - NSCameraUsageDescription - Grant access to camera to be able to take photos and videos - - diff --git a/ExyteMediaPicker.podspec b/ExyteMediaPicker.podspec deleted file mode 100644 index b5cc127..0000000 --- a/ExyteMediaPicker.podspec +++ /dev/null @@ -1,23 +0,0 @@ -Pod::Spec.new do |s| - s.name = "ExyteMediaPicker" - s.version = "1.2.4" - s.summary = "MediaPicker is a customizable photo/video picker for iOS written in pure SwiftUI" - - s.homepage = 'https://github.com/exyte/MediaPicker.git' - s.license = 'MIT' - s.author = { 'Exyte' => 'info@exyte.com' } - s.source = { :git => 'https://github.com/exyte/MediaPicker.git', :tag => s.version.to_s } - s.social_media_url = 'http://exyte.com' - - s.ios.deployment_target = '15.0' - - s.requires_arc = true - s.swift_version = "5.2" - - s.source_files = [ - 'Sources/*.h', - 'Sources/*.swift', - 'Sources/**/*.swift' - ] - -end diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/MediaPickerExample/MediaPickerExample.xcodeproj/project.pbxproj similarity index 52% rename from Examples/Examples.xcodeproj/project.pbxproj rename to MediaPickerExample/MediaPickerExample.xcodeproj/project.pbxproj index 5dd69f1..a9c3491 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/MediaPickerExample/MediaPickerExample.xcodeproj/project.pbxproj @@ -3,218 +3,191 @@ archiveVersion = 1; classes = { }; - objectVersion = 55; + objectVersion = 56; objects = { /* Begin PBXBuildFile section */ - 130FBF5528740D3E0086478A /* CustomizedMediaPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130FBF5428740D3E0086478A /* CustomizedMediaPicker.swift */; }; - 1384FF0B283F912E00C9AAC1 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1384FF0A283F912E00C9AAC1 /* Application.swift */; }; - 1384FF0D283F912E00C9AAC1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1384FF0C283F912E00C9AAC1 /* ContentView.swift */; }; - 1384FF0F283F912F00C9AAC1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1384FF0E283F912F00C9AAC1 /* Assets.xcassets */; }; - 1384FF12283F912F00C9AAC1 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1384FF11283F912F00C9AAC1 /* Preview Assets.xcassets */; }; - 5B1CA4E029F137E100245B8B /* ExyteMediaPicker in Frameworks */ = {isa = PBXBuildFile; productRef = 5B1CA4DF29F137E100245B8B /* ExyteMediaPicker */; }; - 5B5AA87329F2923A001306F9 /* MediaCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5AA87229F2923A001306F9 /* MediaCell.swift */; }; - 5B92AABC2A162DB3002F9EB2 /* FilterMediaPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B92AABB2A162DB3002F9EB2 /* FilterMediaPicker.swift */; }; - A82559832901A437008D9CC5 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A82559822901A437008D9CC5 /* AppDelegate.swift */; }; + 5B2068782D9BE32700485134 /* ExyteMediaPicker in Frameworks */ = {isa = PBXBuildFile; productRef = 5B2068772D9BE32700485134 /* ExyteMediaPicker */; }; + 5BFF684D2AD68B990099D333 /* MediaCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFF68432AD68B990099D333 /* MediaCell.swift */; }; + 5BFF684E2AD68B990099D333 /* FilterMediaPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFF68442AD68B990099D333 /* FilterMediaPicker.swift */; }; + 5BFF684F2AD68B990099D333 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5BFF68452AD68B990099D333 /* Assets.xcassets */; }; + 5BFF68502AD68B990099D333 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5BFF68472AD68B990099D333 /* Preview Assets.xcassets */; }; + 5BFF68512AD68B990099D333 /* CustomizedMediaPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFF68482AD68B990099D333 /* CustomizedMediaPicker.swift */; }; + 5BFF68522AD68B990099D333 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFF68492AD68B990099D333 /* AppDelegate.swift */; }; + 5BFF68532AD68B990099D333 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFF684A2AD68B990099D333 /* Application.swift */; }; + 5BFF68542AD68B990099D333 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFF684B2AD68B990099D333 /* ContentView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 130FBF5428740D3E0086478A /* CustomizedMediaPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizedMediaPicker.swift; sourceTree = ""; }; - 1384FF07283F912E00C9AAC1 /* Example-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Example-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 1384FF0A283F912E00C9AAC1 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; - 1384FF0C283F912E00C9AAC1 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - 1384FF0E283F912F00C9AAC1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 1384FF11283F912F00C9AAC1 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - 1384FF19283F920A00C9AAC1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 5B1CA4DE29F137A600245B8B /* MediaPicker */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = MediaPicker; path = ..; sourceTree = ""; }; - 5B5AA87229F2923A001306F9 /* MediaCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaCell.swift; sourceTree = ""; }; - 5B92AABB2A162DB3002F9EB2 /* FilterMediaPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterMediaPicker.swift; sourceTree = ""; }; - A82559822901A437008D9CC5 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 5B2068742D9BE17100485134 /* MediaPicker */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = MediaPicker; path = ..; sourceTree = ""; }; + 5B2068752D9BE32400485134 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 5BFF68312AD689C80099D333 /* MediaPickerExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MediaPickerExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 5BFF68432AD68B990099D333 /* MediaCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaCell.swift; sourceTree = ""; }; + 5BFF68442AD68B990099D333 /* FilterMediaPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilterMediaPicker.swift; sourceTree = ""; }; + 5BFF68452AD68B990099D333 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 5BFF68472AD68B990099D333 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 5BFF68482AD68B990099D333 /* CustomizedMediaPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizedMediaPicker.swift; sourceTree = ""; }; + 5BFF68492AD68B990099D333 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 5BFF684A2AD68B990099D333 /* Application.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; + 5BFF684B2AD68B990099D333 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 1384FF04283F912E00C9AAC1 /* Frameworks */ = { + 5BFF682E2AD689C80099D333 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5B1CA4E029F137E100245B8B /* ExyteMediaPicker in Frameworks */, + 5B2068782D9BE32700485134 /* ExyteMediaPicker in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 130FBF5628740FB30086478A /* Support Files */ = { + 5B2068762D9BE32700485134 /* Frameworks */ = { isa = PBXGroup; children = ( - 1384FF0E283F912F00C9AAC1 /* Assets.xcassets */, - 1384FF19283F920A00C9AAC1 /* Info.plist */, - 1384FF10283F912F00C9AAC1 /* Preview Content */, - ); - name = "Support Files"; - sourceTree = ""; - }; - 1384FEFA283F90C800C9AAC1 = { - isa = PBXGroup; - children = ( - 1384FF01283F90FA00C9AAC1 /* Packages */, - 1384FF09283F912E00C9AAC1 /* Example-iOS */, - 1384FF08283F912E00C9AAC1 /* Products */, - 1384FF16283F919C00C9AAC1 /* Frameworks */, ); + name = Frameworks; sourceTree = ""; }; - 1384FF01283F90FA00C9AAC1 /* Packages */ = { + 5BFF68282AD689C80099D333 = { isa = PBXGroup; children = ( - 5B1CA4DE29F137A600245B8B /* MediaPicker */, + 5B2068742D9BE17100485134 /* MediaPicker */, + 5BFF68422AD68B990099D333 /* MediaPickerExample */, + 5BFF68322AD689C80099D333 /* Products */, + 5B2068762D9BE32700485134 /* Frameworks */, ); - name = Packages; sourceTree = ""; }; - 1384FF08283F912E00C9AAC1 /* Products */ = { + 5BFF68322AD689C80099D333 /* Products */ = { isa = PBXGroup; children = ( - 1384FF07283F912E00C9AAC1 /* Example-iOS.app */, + 5BFF68312AD689C80099D333 /* MediaPickerExample.app */, ); name = Products; sourceTree = ""; }; - 1384FF09283F912E00C9AAC1 /* Example-iOS */ = { + 5BFF68422AD68B990099D333 /* MediaPickerExample */ = { isa = PBXGroup; children = ( - 130FBF5628740FB30086478A /* Support Files */, - 1384FF0A283F912E00C9AAC1 /* Application.swift */, - 130FBF5428740D3E0086478A /* CustomizedMediaPicker.swift */, - 1384FF0C283F912E00C9AAC1 /* ContentView.swift */, - A82559822901A437008D9CC5 /* AppDelegate.swift */, - 5B5AA87229F2923A001306F9 /* MediaCell.swift */, - 5B92AABB2A162DB3002F9EB2 /* FilterMediaPicker.swift */, + 5B2068752D9BE32400485134 /* Info.plist */, + 5BFF684A2AD68B990099D333 /* Application.swift */, + 5BFF68492AD68B990099D333 /* AppDelegate.swift */, + 5BFF684B2AD68B990099D333 /* ContentView.swift */, + 5BFF68432AD68B990099D333 /* MediaCell.swift */, + 5BFF68482AD68B990099D333 /* CustomizedMediaPicker.swift */, + 5BFF68442AD68B990099D333 /* FilterMediaPicker.swift */, + 5BFF68452AD68B990099D333 /* Assets.xcassets */, + 5BFF68462AD68B990099D333 /* Preview Content */, ); - path = "Example-iOS"; + path = MediaPickerExample; sourceTree = ""; }; - 1384FF10283F912F00C9AAC1 /* Preview Content */ = { + 5BFF68462AD68B990099D333 /* Preview Content */ = { isa = PBXGroup; children = ( - 1384FF11283F912F00C9AAC1 /* Preview Assets.xcassets */, + 5BFF68472AD68B990099D333 /* Preview Assets.xcassets */, ); path = "Preview Content"; sourceTree = ""; }; - 1384FF16283F919C00C9AAC1 /* Frameworks */ = { - isa = PBXGroup; - children = ( - ); - name = Frameworks; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 1384FF06283F912E00C9AAC1 /* Example-iOS */ = { + 5BFF68302AD689C80099D333 /* MediaPickerExample */ = { isa = PBXNativeTarget; - buildConfigurationList = 1384FF13283F912F00C9AAC1 /* Build configuration list for PBXNativeTarget "Example-iOS" */; + buildConfigurationList = 5BFF683F2AD689C90099D333 /* Build configuration list for PBXNativeTarget "MediaPickerExample" */; buildPhases = ( - 1384FF03283F912E00C9AAC1 /* Sources */, - 1384FF04283F912E00C9AAC1 /* Frameworks */, - 1384FF05283F912E00C9AAC1 /* Resources */, + 5BFF682D2AD689C80099D333 /* Sources */, + 5BFF682E2AD689C80099D333 /* Frameworks */, + 5BFF682F2AD689C80099D333 /* Resources */, ); buildRules = ( ); dependencies = ( ); - name = "Example-iOS"; + name = MediaPickerExample; packageProductDependencies = ( - 5B1CA4DF29F137E100245B8B /* ExyteMediaPicker */, + 5B2068772D9BE32700485134 /* ExyteMediaPicker */, ); - productName = "Example-iOS"; - productReference = 1384FF07283F912E00C9AAC1 /* Example-iOS.app */; + productName = MediaPickerExample; + productReference = 5BFF68312AD689C80099D333 /* MediaPickerExample.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ - 1384FEFB283F90C800C9AAC1 /* Project object */ = { + 5BFF68292AD689C80099D333 /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1340; - LastUpgradeCheck = 1340; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; TargetAttributes = { - 1384FF06283F912E00C9AAC1 = { - CreatedOnToolsVersion = 13.4; + 5BFF68302AD689C80099D333 = { + CreatedOnToolsVersion = 15.0; }; }; }; - buildConfigurationList = 1384FEFE283F90C800C9AAC1 /* Build configuration list for PBXProject "Examples" */; - compatibilityVersion = "Xcode 13.0"; + buildConfigurationList = 5BFF682C2AD689C80099D333 /* Build configuration list for PBXProject "MediaPickerExample" */; + compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); - mainGroup = 1384FEFA283F90C800C9AAC1; - productRefGroup = 1384FF08283F912E00C9AAC1 /* Products */; + mainGroup = 5BFF68282AD689C80099D333; + packageReferences = ( + ); + productRefGroup = 5BFF68322AD689C80099D333 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - 1384FF06283F912E00C9AAC1 /* Example-iOS */, + 5BFF68302AD689C80099D333 /* MediaPickerExample */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 1384FF05283F912E00C9AAC1 /* Resources */ = { + 5BFF682F2AD689C80099D333 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 1384FF12283F912F00C9AAC1 /* Preview Assets.xcassets in Resources */, - 1384FF0F283F912F00C9AAC1 /* Assets.xcassets in Resources */, + 5BFF68502AD68B990099D333 /* Preview Assets.xcassets in Resources */, + 5BFF684F2AD68B990099D333 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 1384FF03283F912E00C9AAC1 /* Sources */ = { + 5BFF682D2AD689C80099D333 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - A82559832901A437008D9CC5 /* AppDelegate.swift in Sources */, - 1384FF0D283F912E00C9AAC1 /* ContentView.swift in Sources */, - 5B5AA87329F2923A001306F9 /* MediaCell.swift in Sources */, - 130FBF5528740D3E0086478A /* CustomizedMediaPicker.swift in Sources */, - 5B92AABC2A162DB3002F9EB2 /* FilterMediaPicker.swift in Sources */, - 1384FF0B283F912E00C9AAC1 /* Application.swift in Sources */, + 5BFF68522AD68B990099D333 /* AppDelegate.swift in Sources */, + 5BFF68532AD68B990099D333 /* Application.swift in Sources */, + 5BFF68542AD68B990099D333 /* ContentView.swift in Sources */, + 5BFF68512AD68B990099D333 /* CustomizedMediaPicker.swift in Sources */, + 5BFF684D2AD68B990099D333 /* MediaCell.swift in Sources */, + 5BFF684E2AD68B990099D333 /* FilterMediaPicker.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ - 1384FEFF283F90C800C9AAC1 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - }; - name = Debug; - }; - 1384FF00283F90C800C9AAC1 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - }; - name = Release; - }; - 1384FF14283F912F00C9AAC1 /* Debug */ = { + 5BFF683D2AD689C90099D333 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; @@ -240,16 +213,12 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_ASSET_PATHS = "\"Example-iOS/Preview Content\""; - DEVELOPMENT_TEAM = FZXCM5CJ7P; - ENABLE_PREVIEWS = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; @@ -263,42 +232,26 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "Example-iOS/Info.plist"; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "com.example.media-picker"; - PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_VERSION = ""; }; name = Debug; }; - 1384FF15283F912F00C9AAC1 /* Release */ = { + 5BFF683E2AD689C90099D333 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; @@ -324,16 +277,12 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_ASSET_PATHS = "\"Example-iOS/Preview Content\""; - DEVELOPMENT_TEAM = FZXCM5CJ7P; ENABLE_NS_ASSERTIONS = NO; - ENABLE_PREVIEWS = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; @@ -341,50 +290,110 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = ""; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 5BFF68402AD689C90099D333 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"MediaPickerExample/Preview Content\""; + DEVELOPMENT_TEAM = FZXCM5CJ7P; + ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "Example-iOS/Info.plist"; + INFOPLIST_FILE = MediaPickerExample/Info.plist; + INFOPLIST_KEY_NSCameraUsageDescription = "Grant access to camera to be able to take photos and videos"; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Grant access to microphone to be able to take videos"; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Grant access to photo library to be able to select photos"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "com.example.media-picker"; + PRODUCT_BUNDLE_IDENTIFIER = com.exyte.MediaPickerExample; PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 5BFF68412AD689C90099D333 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"MediaPickerExample/Preview Content\""; + DEVELOPMENT_TEAM = FZXCM5CJ7P; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MediaPickerExample/Info.plist; + INFOPLIST_KEY_NSCameraUsageDescription = "Grant access to camera to be able to take photos and videos"; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Grant access to microphone to be able to take videos"; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Grant access to photo library to be able to select photos"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.exyte.MediaPickerExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 1384FEFE283F90C800C9AAC1 /* Build configuration list for PBXProject "Examples" */ = { + 5BFF682C2AD689C80099D333 /* Build configuration list for PBXProject "MediaPickerExample" */ = { isa = XCConfigurationList; buildConfigurations = ( - 1384FEFF283F90C800C9AAC1 /* Debug */, - 1384FF00283F90C800C9AAC1 /* Release */, + 5BFF683D2AD689C90099D333 /* Debug */, + 5BFF683E2AD689C90099D333 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 1384FF13283F912F00C9AAC1 /* Build configuration list for PBXNativeTarget "Example-iOS" */ = { + 5BFF683F2AD689C90099D333 /* Build configuration list for PBXNativeTarget "MediaPickerExample" */ = { isa = XCConfigurationList; buildConfigurations = ( - 1384FF14283F912F00C9AAC1 /* Debug */, - 1384FF15283F912F00C9AAC1 /* Release */, + 5BFF68402AD689C90099D333 /* Debug */, + 5BFF68412AD689C90099D333 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -392,11 +401,11 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ - 5B1CA4DF29F137E100245B8B /* ExyteMediaPicker */ = { + 5B2068772D9BE32700485134 /* ExyteMediaPicker */ = { isa = XCSwiftPackageProductDependency; productName = ExyteMediaPicker; }; /* End XCSwiftPackageProductDependency section */ }; - rootObject = 1384FEFB283F90C800C9AAC1 /* Project object */; + rootObject = 5BFF68292AD689C80099D333 /* Project object */; } diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/MediaPickerExample/MediaPickerExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from Examples/Examples.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to MediaPickerExample/MediaPickerExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/MediaPickerExample/MediaPickerExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to MediaPickerExample/MediaPickerExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/MediaPickerExample/MediaPickerExample.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/MediaPickerExample/MediaPickerExample.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/MediaPickerExample/MediaPickerExample.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/MediaPickerExample/MediaPickerExample.xcodeproj/xcshareddata/xcschemes/MediaPickerExample.xcscheme b/MediaPickerExample/MediaPickerExample.xcodeproj/xcshareddata/xcschemes/MediaPickerExample.xcscheme new file mode 100644 index 0000000..949dbd1 --- /dev/null +++ b/MediaPickerExample/MediaPickerExample.xcodeproj/xcshareddata/xcschemes/MediaPickerExample.xcscheme @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MediaPickerExample/MediaPickerExample/AppDelegate.swift b/MediaPickerExample/MediaPickerExample/AppDelegate.swift new file mode 100644 index 0000000..34e976e --- /dev/null +++ b/MediaPickerExample/MediaPickerExample/AppDelegate.swift @@ -0,0 +1,44 @@ +// +// AppDelegate.swift +// Example-iOS +// +// Created by Alexandra Afonasova on 20.10.2022. +// + +import Foundation +import UIKit + +final class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject { + + private var orientationLock = UIInterfaceOrientationMask.all + + func lockOrientationToPortrait() { + orientationLock = .portrait + if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene { + scene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait)) + } + } + + func unlockOrientation() { + orientationLock = .all + if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene { + let currentOrientation = UIDevice.current.orientation + let newOrientation: UIInterfaceOrientationMask + + switch currentOrientation { + case .portrait: newOrientation = .portrait + case .portraitUpsideDown: newOrientation = .portraitUpsideDown + case .landscapeLeft: newOrientation = .landscapeLeft + case .landscapeRight: newOrientation = .landscapeRight + default: newOrientation = .all + } + + scene.requestGeometryUpdate(.iOS(interfaceOrientations: newOrientation)) + } + } + + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + return orientationLock + } + +} diff --git a/Examples/Example-iOS/Application.swift b/MediaPickerExample/MediaPickerExample/Application.swift similarity index 88% rename from Examples/Example-iOS/Application.swift rename to MediaPickerExample/MediaPickerExample/Application.swift index 9f3f296..c3dddd7 100644 --- a/Examples/Example-iOS/Application.swift +++ b/MediaPickerExample/MediaPickerExample/Application.swift @@ -16,6 +16,7 @@ struct Application: App { WindowGroup { ContentView() .environmentObject(appDelegate) + .preferredColorScheme(.dark) } } } diff --git a/Examples/Example-iOS/Assets.xcassets/AccentColor.colorset/Contents.json b/MediaPickerExample/MediaPickerExample/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Examples/Example-iOS/Assets.xcassets/AccentColor.colorset/Contents.json rename to MediaPickerExample/MediaPickerExample/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Examples/Example-iOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/MediaPickerExample/MediaPickerExample/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from Examples/Example-iOS/Assets.xcassets/AppIcon.appiconset/Contents.json rename to MediaPickerExample/MediaPickerExample/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/Examples/Example-iOS/Assets.xcassets/chevron.imageset/Contents.json b/MediaPickerExample/MediaPickerExample/Assets.xcassets/Chevron.imageset/Contents.json similarity index 100% rename from Examples/Example-iOS/Assets.xcassets/chevron.imageset/Contents.json rename to MediaPickerExample/MediaPickerExample/Assets.xcassets/Chevron.imageset/Contents.json diff --git a/Examples/Example-iOS/Assets.xcassets/chevron.imageset/chevron.pdf b/MediaPickerExample/MediaPickerExample/Assets.xcassets/Chevron.imageset/chevron.pdf similarity index 100% rename from Examples/Example-iOS/Assets.xcassets/chevron.imageset/chevron.pdf rename to MediaPickerExample/MediaPickerExample/Assets.xcassets/Chevron.imageset/chevron.pdf diff --git a/Examples/Example-iOS/Assets.xcassets/Contents.json b/MediaPickerExample/MediaPickerExample/Assets.xcassets/Contents.json similarity index 100% rename from Examples/Example-iOS/Assets.xcassets/Contents.json rename to MediaPickerExample/MediaPickerExample/Assets.xcassets/Contents.json diff --git a/Examples/Example-iOS/Assets.xcassets/CustomGreen.colorset/Contents.json b/MediaPickerExample/MediaPickerExample/Assets.xcassets/CustomGreen.colorset/Contents.json similarity index 100% rename from Examples/Example-iOS/Assets.xcassets/CustomGreen.colorset/Contents.json rename to MediaPickerExample/MediaPickerExample/Assets.xcassets/CustomGreen.colorset/Contents.json diff --git a/Examples/Example-iOS/Assets.xcassets/CustomPurple.colorset/Contents.json b/MediaPickerExample/MediaPickerExample/Assets.xcassets/CustomPurple.colorset/Contents.json similarity index 100% rename from Examples/Example-iOS/Assets.xcassets/CustomPurple.colorset/Contents.json rename to MediaPickerExample/MediaPickerExample/Assets.xcassets/CustomPurple.colorset/Contents.json diff --git a/Examples/Example-iOS/ContentView.swift b/MediaPickerExample/MediaPickerExample/ContentView.swift similarity index 94% rename from Examples/Example-iOS/ContentView.swift rename to MediaPickerExample/MediaPickerExample/ContentView.swift index 9785f6a..5ae8aae 100644 --- a/Examples/Example-iOS/ContentView.swift +++ b/MediaPickerExample/MediaPickerExample/ContentView.swift @@ -7,7 +7,6 @@ import SwiftUI import ExyteMediaPicker -import Combine struct ContentView: View { @@ -54,12 +53,13 @@ struct ContentView: View { } // MARK: - Default media picker - .sheet(isPresented: $showDefaultMediaPicker) { + .fullScreenCover(isPresented: $showDefaultMediaPicker) { MediaPicker( isPresented: $showDefaultMediaPicker, onChange: { medias = $0 } ) - .showLiveCameraCell() + .mediaSelectionType(.photoAndVideo) + .mediaSelectionStyle(.count) .orientationHandler { switch $0 { case .lock: appDelegate.lockOrientationToPortrait() diff --git a/Examples/Example-iOS/CustomizedMediaPicker.swift b/MediaPickerExample/MediaPickerExample/CustomizedMediaPicker.swift similarity index 61% rename from Examples/Example-iOS/CustomizedMediaPicker.swift rename to MediaPickerExample/MediaPickerExample/CustomizedMediaPicker.swift index 4eca860..8f47832 100644 --- a/Examples/Example-iOS/CustomizedMediaPicker.swift +++ b/MediaPickerExample/MediaPickerExample/CustomizedMediaPicker.swift @@ -17,7 +17,9 @@ struct CustomizedMediaPicker: View { @State private var mediaPickerMode = MediaPickerMode.photos @State private var selectedAlbum: Album? + @State private var currentFullscreenMedia: Media? @State private var showAlbumsDropDown: Bool = false + @State private var videoIsBeingRecorded: Bool = false let maxCount: Int = 5 @@ -25,12 +27,13 @@ struct CustomizedMediaPicker: View { MediaPicker( isPresented: $isPresented, onChange: { medias = $0 }, - albumSelectionBuilder: { _, albumSelectionView in + albumSelectionBuilder: { _, albumSelectionView, _ in VStack { headerView albumSelectionView Spacer() footerView + .background(Color.black) } .background(Color.black) }, @@ -40,28 +43,50 @@ struct CustomizedMediaPicker: View { Spacer() Button("Done", action: { isPresented = false }) } + .padding() cameraSelectionView HStack { Button("Cancel", action: cancelClosure) Spacer() Button(action: addMoreClosure) { Text("Take more photos") - .font(.headline) - .foregroundColor(.black) - .padding() - } - .background { - Color("CustomGreen") - .cornerRadius(16) + .greenButtonStyle() } } + .padding() } .background(Color.black) + }, + cameraViewBuilder: { cameraSheetView, cancelClosure, showPreviewClosure, takePhotoClosure, startVideoCaptureClosure, stopVideoCaptureClosure, _, _ in + cameraSheetView + .overlay(alignment: .topLeading) { + HStack { + Button("Cancel") { cancelClosure() } + .foregroundColor(Color("CustomPurple")) + Spacer() + Button("Done") { showPreviewClosure() } + .foregroundColor(Color("CustomPurple")) + } + .padding() + } + .overlay(alignment: .bottom) { + HStack { + Button("Take photo") { takePhotoClosure() } + .greenButtonStyle() + Button(videoIsBeingRecorded ? "Stop video capture" : "Capture video") { + videoIsBeingRecorded ? stopVideoCaptureClosure() : startVideoCaptureClosure() + videoIsBeingRecorded.toggle() + } + .greenButtonStyle() + } + .padding() + } } ) - .showLiveCameraCell() + .liveCameraCell(.prominant) .albums($albums) .pickerMode($mediaPickerMode) + .currentFullscreenMedia($currentFullscreenMedia) .orientationHandler { switch $0 { case .lock: appDelegate.lockOrientationToPortrait() @@ -72,15 +97,15 @@ struct CustomizedMediaPicker: View { .mediaSelectionLimit(maxCount) .mediaPickerTheme( main: .init( - albumSelectionBackground: .black, + pickerBackground: .black, fullscreenPhotoBackground: .black ), selection: .init( - emptyTint: .white, - emptyBackground: .black.opacity(0.25), - selectedTint: Color("CustomPurple"), - fullscreenTint: .white - ) + cellSelectedBorder: .customPurple, + cellSelectedBackground: .customPurple, + fullscreenEmptyBorder: .customPurple, + fullscreenSelectedBorder: .customPurple, + fullscreenSelectedBackground: .customPurple) ) .overlay(alignment: .topLeading) { if showAlbumsDropDown { @@ -116,13 +141,12 @@ struct CustomizedMediaPicker: View { var footerView: some View { HStack { - Button { - medias = [] - isPresented = false - } label: { - Text("Cancel") - .foregroundColor(.white.opacity(0.7)) - } + TextField("", text: .constant(""), prompt: Text("Add a caption").foregroundColor(.gray)) + .padding() + .background { + Color.white.opacity(0.2) + .cornerRadius(6) + } Spacer(minLength: 70) @@ -137,18 +161,12 @@ struct CustomizedMediaPicker: View { .background(Color.white) .clipShape(Circle()) } - .font(.headline) - .foregroundColor(.black) - .padding(.horizontal) - .padding(.vertical, 15) .frame(maxWidth: .infinity) } - .background { - Color("CustomGreen") - .cornerRadius(16) - } + .greenButtonStyle() } .padding(.horizontal) + .padding(.vertical, 8) } var albumsDropdown: some View { @@ -167,3 +185,15 @@ struct CustomizedMediaPicker: View { .frame(maxHeight: 300) } } + +extension View { + func greenButtonStyle() -> some View { + self.font(.headline) + .foregroundColor(.black) + .padding() + .background { + Color("CustomGreen") + .cornerRadius(16) + } + } +} diff --git a/Examples/Example-iOS/FilterMediaPicker.swift b/MediaPickerExample/MediaPickerExample/FilterMediaPicker.swift similarity index 90% rename from Examples/Example-iOS/FilterMediaPicker.swift rename to MediaPickerExample/MediaPickerExample/FilterMediaPicker.swift index 08b879f..cd734a4 100644 --- a/Examples/Example-iOS/FilterMediaPicker.swift +++ b/MediaPickerExample/MediaPickerExample/FilterMediaPicker.swift @@ -19,18 +19,18 @@ struct FilterMediaPicker: View { isPresented: $isPresented, onChange: { medias = $0 } ) - .applyFilter { await isMostlyBlueAndGreen($0) } + .applyFilter { await isMostlyRed($0) } } - private func isMostlyBlueAndGreen(_ media: Media) async -> Media? { + private func isMostlyRed(_ media: Media) async -> Media? { guard let data = await media.getThumbnailData() else { return nil } guard let uiImage = UIImage(data: data) else { return nil } let color = uiImage.averageColor var red: CGFloat = 0.0, green: CGFloat = 0.0, blue: CGFloat = 0.0, alpha: CGFloat = 0.0 color?.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - if blue > red, green > red { - return media + if red > blue, red > green { + return media } else { return nil } diff --git a/MediaPickerExample/MediaPickerExample/Info.plist b/MediaPickerExample/MediaPickerExample/Info.plist new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/MediaPickerExample/MediaPickerExample/Info.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/Examples/Example-iOS/MediaCell.swift b/MediaPickerExample/MediaPickerExample/MediaCell.swift similarity index 90% rename from Examples/Example-iOS/MediaCell.swift rename to MediaPickerExample/MediaPickerExample/MediaCell.swift index ef09dfc..b263ea5 100644 --- a/Examples/Example-iOS/MediaCell.swift +++ b/MediaPickerExample/MediaPickerExample/MediaCell.swift @@ -34,8 +34,8 @@ struct MediaCell: View { } } .frame(width: g.size.width, height: g.size.height) - .clipped() } + .clipped() .task { await viewModel.onStart() } @@ -45,6 +45,7 @@ struct MediaCell: View { } } +@MainActor final class MediaCellViewModel: ObservableObject { let media: Media @@ -64,12 +65,12 @@ final class MediaCellViewModel: ObservableObject { let url = await media.getURL() guard let url = url else { return } - DispatchQueue.main.async { - switch self.media.type { + DispatchQueue.main.async { [weak self, media] in + switch media.type { case .image: - self.imageUrl = url + self?.imageUrl = url case .video: - self.player = AVPlayer(url: url) + self?.player = AVPlayer(url: url) } } } diff --git a/Examples/Example-iOS/Preview Content/Preview Assets.xcassets/Contents.json b/MediaPickerExample/MediaPickerExample/Preview Content/Preview Assets.xcassets/Contents.json similarity index 100% rename from Examples/Example-iOS/Preview Content/Preview Assets.xcassets/Contents.json rename to MediaPickerExample/MediaPickerExample/Preview Content/Preview Assets.xcassets/Contents.json diff --git a/Package.swift b/Package.swift index 4b56e26..3288c9d 100644 --- a/Package.swift +++ b/Package.swift @@ -1,24 +1,32 @@ -// swift-tools-version: 5.6 -// The swift-tools-version declares the minimum version of Swift required to build this package. +// swift-tools-version: 5.9 import PackageDescription let package = Package( name: "ExyteMediaPicker", platforms: [ - .iOS(.v15), - .macOS(.v11) + .iOS(.v17) ], products: [ .library( name: "ExyteMediaPicker", targets: ["ExyteMediaPicker"]), ], - dependencies: [], + dependencies: [ + .package( + url: "https://github.com/exyte/AnchoredPopup.git", + from: "1.0.0" + ) + ], targets: [ .target( name: "ExyteMediaPicker", - dependencies: [] + dependencies: [ + .product(name: "AnchoredPopup", package: "AnchoredPopup") + ], + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency") + ] ), .testTarget( name: "MediaPickerTests", diff --git a/README.md b/README.md index ec4faf0..ae039fc 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,8 @@ ![](https://img.shields.io/github/v/tag/exyte/MediaPicker?label=Version) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fexyte%2FMediaPicker%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/exyte/MediaPicker) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fexyte%2FMediaPicker%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/exyte/MediaPicker) -[![SPM Compatible](https://img.shields.io/badge/SwiftPM-Compatible-brightgreen.svg)](https://swiftpackageindex.com/exyte/MediaPicker) -[![Cocoapods Compatible](https://img.shields.io/badge/cocoapods-Compatible-brightgreen.svg)](https://cocoapods.org/pods/ExyteMediaPicker) -[![Carthage Compatible](https://img.shields.io/badge/Carthage-compatible-brightgreen.svg?style=flat)](https://github.com/Carthage/Carthage) +[![SPM](https://img.shields.io/badge/SPM-Compatible-brightgreen.svg)](https://swiftpackageindex.com/exyte/MediaPicker) +[![Cocoapods](https://img.shields.io/badge/Cocoapods-Deprecated%20after%202.2.3-yellow.svg)](https://cocoapods.org/pods/ExyteMediaPicker) [![License: MIT](https://img.shields.io/badge/License-MIT-black.svg)](https://opensource.org/licenses/MIT) # Features @@ -68,14 +67,16 @@ After making one photo, you see a preview of it and a little plus icon, by tappi `onChange` - a closure that returns the selected media every time the selection changes ### Init - optional view builders -You can pass two view builders in order to add your own buttons and other elements to media picker screens. First screen you can customize is default photos grid view. Pass `albumSelectionBuilder` closure like this to replace the standard one with your own view: +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: ```swift MediaPicker( isPresented: $isPresented, onChange: { selectedMedia = $0 }, - albumSelectionBuilder: { defaultHeaderView, albumSelectionView in + albumSelectionBuilder: { defaultHeaderView, albumSelectionView, isInFullscreen in VStack { - defaultHeaderView + if !isInFullscreen { + defaultHeaderView + } albumSelectionView Spacer() footerView @@ -88,6 +89,7 @@ MediaPicker( `albumSelectionBuilder` gives you two views to work with: - `defaultHeaderView` - a default looking `header` with photos/albums mode switcher - `albumSelectionView` - the photos grid itself +- `isInFullscreen` - is fullscreen photo details screen displayed. Use for example to hide the header while in fullscreen mode. The second customizable screen is the one you see after taking a photo. Pass `cameraSelectionBuilder` like this: ```swift @@ -117,13 +119,56 @@ MediaPicker( - `cancelClosure` - show confirmation and return to photos grid screen if confirmed - `cameraSelectionView` - swipable camera photos preview collection itself -You can pass one, both or none of these when creating your `MediaPicker`. (see the custom picker in the example project for usage example) +The last one is live camera screen + +```swift +MediaPicker( + isPresented: $isPresented, + onChange: { selectedMedia = $0 }, + cameraViewBuilder: { cameraSheetView, cancelClosure, showPreviewClosure, takePhotoClosure, startVideoCaptureClosure, stopVideoCaptureClosure, toggleFlash, flipCamera in + cameraSheetView + .overlay(alignment: .topLeading) { + HStack { + Button("Cancel") { cancelClosure() } + .foregroundColor(Color("CustomPurple")) + Spacer() + Button("Done") { showPreviewClosure() } + .foregroundColor(Color("CustomPurple")) + } + .padding() + } + .overlay(alignment: .bottom) { + HStack { + Button("Take photo") { takePhotoClosure() } + .greenButtonStyle() + Button(videoIsBeingRecorded ? "Stop video capture" : "Capture video") { + videoIsBeingRecorded ? stopVideoCaptureClosure() : startVideoCaptureClosure() + videoIsBeingRecorded.toggle() + } + .greenButtonStyle() + } + .padding() + } + } +) +``` + +`cameraViewBuilder` live camera capture view and a lot of closures to do with as you please: +- `cameraSheetView` - live camera capture view +- `cancelClosure` - if you want to display "are you sure" before closing +- `showPreviewClosure` - shows preview of taken photos +- `cancelClosure` - if you want to display "are you sure" before closing +- `startVideoCaptureClosure` - starts video capture, you'll need a bollean varialbe to track recording state +- `stopVideoCaptureClosure` - stops video capture +- `toggleFlash` - flash off/on +- `flipCamera` - camera back/front ## Available modifiers -`showLiveCameraCell` - show live camera feed cell in the top left corner of the gallery grid +`liveCameraCell` - show live camera feed cell in the top left corner of the gallery grid, type .none, .small, .prominant (2 cell height) defaults to .small `mediaSelectionType` - limit displayed media type: .photo, .video or both `mediaSelectionStyle` - a way to display selected/unselected media state: a counter or a simple checkmark `mediaSelectionLimit` - the maximum selection quantity allowed, 'nil' for unlimited selection +`showFullscreenPreview` - if true - tap on media opens fullscreen preview, if false - tap on image immediately selects this image and closes the picker ### Available modifiers - filtering `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) @@ -169,8 +214,8 @@ Here is an example of how you can customize colors and elements to create a cust To try out the MediaPicker examples: - Clone the repo `https://github.com/exyte/MediaPicker.git` -- Open `Examples/Examples.xcworkspace` in the Xcode -- Run it! +- Open `MediaPickerExample.xcodeproj` in the Xcode +- Try it! ## Installation @@ -182,32 +227,24 @@ dependencies: [ ] ``` -### CocoaPods - -```ruby -pod 'ExyteMediaPicker' -``` - -### Carthage - -```ogdl -github "Exyte/MediaPicker" -``` - ## Requirements -* iOS 15+ -* Xcode 13+ +* iOS 17+ +* Xcode 15+ ## Our other open source SwiftUI libraries [PopupView](https://github.com/exyte/PopupView) - Toasts and popups library +[AnchoredPopup](https://github.com/exyte/AnchoredPopup) - Anchored Popup grows "out" of a trigger view (similar to Hero animation) [Grid](https://github.com/exyte/Grid) - The most powerful Grid container -[ScalingHeaderScrollView](https://github.com/exyte/ScalingHeaderScrollView) - A scroll view with a sticky header which shrinks as you scroll -[AnimatedTabBar](https://github.com/exyte/AnimatedTabBar) - A tabbar with number of preset animations -[Chat](https://github.com/exyte/chat) - Chat UI framework with fully customizable message cells, input view, and a built-in media picker +[ScalingHeaderScrollView](https://github.com/exyte/ScalingHeaderScrollView) - A scroll view with a sticky header which shrinks as you scroll +[AnimatedTabBar](https://github.com/exyte/AnimatedTabBar) - A tabbar with a number of preset animations +[Chat](https://github.com/exyte/chat) - Chat UI framework with fully customizable message cells, input view, and a built-in media picker +[OpenAI](https://github.com/exyte/OpenAI) Wrapper lib for [OpenAI REST API](https://platform.openai.com/docs/api-reference/introduction) +[AnimatedGradient](https://github.com/exyte/AnimatedGradient) - Animated linear gradient [ConcentricOnboarding](https://github.com/exyte/ConcentricOnboarding) - Animated onboarding flow [FloatingButton](https://github.com/exyte/FloatingButton) - Floating button menu [ActivityIndicatorView](https://github.com/exyte/ActivityIndicatorView) - A number of animated loading indicators [ProgressIndicatorView](https://github.com/exyte/ProgressIndicatorView) - A number of animated progress indicators +[FlagAndCountryCode](https://github.com/exyte/FlagAndCountryCode) - Phone codes and flags for every country [SVGView](https://github.com/exyte/SVGView) - SVG parser [LiquidSwipe](https://github.com/exyte/LiquidSwipe) - Liquid navigation animation diff --git a/Sources/ExyteMediaPicker/Drivers/AVFoundation/LiveCameraViewModel.swift b/Sources/ExyteMediaPicker/Drivers/AVFoundation/LiveCameraViewModel.swift deleted file mode 100644 index 30c3f51..0000000 --- a/Sources/ExyteMediaPicker/Drivers/AVFoundation/LiveCameraViewModel.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// Created by Alex.M on 07.06.2022. -// - -import Foundation -import AVFoundation -import CoreImage -import UIKit.UIImage - -/** - Object with live camera preview. Use `captureImage: UIImage` publisher. - - Used solution (with adapt for new iOs version): https://medium.com/ios-os-x-development/ios-camera-frames-extraction-d2c0f80ed05a - */ -class LiveCameraViewModel: NSObject, ObservableObject { - - private let quality = AVCaptureSession.Preset.medium - - private let sessionQueue = DispatchQueue(label: "LiveCameraPreviewQueue") - let captureSession = AVCaptureSession() - - @Published public var capturedImage: UIImage = UIImage() - - override init() { - super.init() - sessionQueue.async { [weak self] in - self?.configureSession() - self?.captureSession.startRunning() - } - } - - func startSession() { - sessionQueue.async { [weak self] in - self?.captureSession.startRunning() - } - } - - func stopSession() { - sessionQueue.async { [weak self] in - self?.captureSession.stopRunning() - } - } - - deinit { - captureSession.stopRunning() - } - - private func configureSession() { - captureSession.beginConfiguration() - captureSession.sessionPreset = quality - guard let captureDevice = captureDevice else { return } - guard let captureDeviceInput = try? AVCaptureDeviceInput(device: captureDevice) else { return } - guard captureSession.canAddInput(captureDeviceInput) else { return } - captureSession.addInput(captureDeviceInput) - captureSession.commitConfiguration() - } - - private var captureDevice: AVCaptureDevice? { - AVCaptureDevice.default(for: .video) - } -} diff --git a/Sources/ExyteMediaPicker/Drivers/Permissions/PermissionsService.swift b/Sources/ExyteMediaPicker/Drivers/Permissions/PermissionsService.swift deleted file mode 100644 index cb98df9..0000000 --- a/Sources/ExyteMediaPicker/Drivers/Permissions/PermissionsService.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// Created by Alex.M on 08.06.2022. -// - -import Foundation -import Combine -import AVFoundation -import Photos - -final class PermissionsService: ObservableObject { - @Published var cameraAction: CameraAction? - @Published var photoLibraryAction: PhotoLibraryAction? - - private var subscriptions = Set() - - init() { - photoLibraryChangePermissionPublisher - .sink { [weak self] in - self?.reload() - } - .store(in: &subscriptions) - reload() - } - - func reload() { - checkCameraAuthorizationStatus() - checkPhotoLibraryAuthorizationStatus() - } - - /// 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 - static func requestPermission(_ permissionGrantedClosure: @escaping ()->()) { - PHPhotoLibrary.requestAuthorization { status in - if status == .authorized || status == .limited { - permissionGrantedClosure() - } - } - } -} - -private extension PermissionsService { - func checkCameraAuthorizationStatus() { - let status = AVCaptureDevice.authorizationStatus(for: .video) - handle(camera: status) - } - - func handle(camera status: AVAuthorizationStatus) { - var result: CameraAction? -#if targetEnvironment(simulator) - result = .unavailable -#else - switch status { - case .notDetermined: - AVCaptureDevice.requestAccess(for: .video) { [weak self] _ in - self?.checkCameraAuthorizationStatus() - } - case .restricted: - result = .unavailable - case .denied: - result = .authorize - case .authorized: - // Do nothing - break - @unknown default: - result = .unknown - } -#endif - DispatchQueue.main.async { [weak self] in - self?.cameraAction = result - } - } - - func checkPhotoLibraryAuthorizationStatus() { - let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) - handle(photoLibrary: status) - } - - func handle(photoLibrary status: PHAuthorizationStatus) { - var result: PhotoLibraryAction? - switch status { - case .notDetermined: - PHPhotoLibrary.requestAuthorization { [weak self] status in - self?.handle(photoLibrary: status) - } - case .restricted: - // TODO: Make sure that access can't change when status == .restricted - result = .unavailable - case .denied: - result = .authorize - case .authorized: - // Do nothing - break - case .limited: - result = .selectMore - @unknown default: - result = .unknown - } - - DispatchQueue.main.async { [weak self] in - self?.photoLibraryAction = result - } - } -} - -extension PermissionsService { - enum CameraAction { - case authorize - case unavailable - case unknown - } - - enum PhotoLibraryAction { - case selectMore - case authorize - case unavailable - case unknown - } -} diff --git a/Sources/ExyteMediaPicker/Drivers/PhotoKit/Albums/AlbumsProviderProtocol.swift b/Sources/ExyteMediaPicker/Drivers/PhotoKit/Albums/AlbumsProviderProtocol.swift deleted file mode 100644 index 7f47f20..0000000 --- a/Sources/ExyteMediaPicker/Drivers/PhotoKit/Albums/AlbumsProviderProtocol.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Created by Alex.M on 09.06.2022. -// - -import Foundation -import Combine - -protocol AlbumsProviderProtocol { - var albums: AnyPublisher<[AlbumModel], Never> { get } - - func reload() -} diff --git a/Sources/ExyteMediaPicker/Drivers/PhotoKit/Albums/DefaultAlbumsProvider.swift b/Sources/ExyteMediaPicker/Drivers/PhotoKit/Albums/DefaultAlbumsProvider.swift deleted file mode 100644 index 28b4cbb..0000000 --- a/Sources/ExyteMediaPicker/Drivers/PhotoKit/Albums/DefaultAlbumsProvider.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// Created by Alex.M on 10.06.2022. -// - -import Foundation -import Combine -import Photos - -final class DefaultAlbumsProvider: AlbumsProviderProtocol { - - private var subject = CurrentValueSubject<[AlbumModel], Never>([]) - private var albumsCancellable: AnyCancellable? - private var permissionCancellable: AnyCancellable? - - var albums: AnyPublisher<[AlbumModel], Never> { - subject.eraseToAnyPublisher() - } - - var mediaSelectionType: MediaSelectionType = .photoAndVideo - - func reload() { - PermissionsService.requestPermission { [ weak self] in - self?.reloadInternal() - } - } - - func reloadInternal() { - albumsCancellable = [PHAssetCollectionType.album, .smartAlbum] - .publisher - .map { fetchAlbums(type: $0) } - .scan([], +) - .receive(on: DispatchQueue.main) - .sink { [weak self] in - self?.subject.send($0) - } - } -} - -private extension DefaultAlbumsProvider { - - func fetchAlbums(type: PHAssetCollectionType) -> [AlbumModel] { - let options = PHFetchOptions() - options.includeAssetSourceTypes = [.typeUserLibrary, .typeiTunesSynced, .typeCloudShared] - options.sortDescriptors = [NSSortDescriptor(key: "localizedTitle", ascending: true)] - - let collections = PHAssetCollection.fetchAssetCollections( - with: type, - subtype: .any, - options: options - ) - - if collections.count == 0 { - return [] - } - var albums: [AlbumModel] = [] - - for index in 0...(collections.count - 1) { - let collection = collections[index] - let options = PHFetchOptions() - options.sortDescriptors = [ - NSSortDescriptor(key: "creationDate", ascending: false) - ] - options.fetchLimit = 1 - let fetchResult = PHAsset.fetchAssets(in: collection, options: options) - if fetchResult.count == 0 { - continue - } - let preview = MediasProvider.map(fetchResult: fetchResult, mediaSelectionType: mediaSelectionType).first - let album = AlbumModel(preview: preview, source: collection) - albums.append(album) - } - return albums - } -} diff --git a/Sources/ExyteMediaPicker/Drivers/PhotoKit/Medias/MediasProviderProtocol.swift b/Sources/ExyteMediaPicker/Drivers/PhotoKit/Medias/MediasProviderProtocol.swift deleted file mode 100644 index ce73c1c..0000000 --- a/Sources/ExyteMediaPicker/Drivers/PhotoKit/Medias/MediasProviderProtocol.swift +++ /dev/null @@ -1,108 +0,0 @@ -// -// Created by Alex.M on 09.06.2022. -// - -import Foundation -import Combine -import Photos -import SwiftUI - -protocol MediasProviderProtocol { - func reload() - func cancel() - - var assetMediaModelsPublisher: CurrentValueSubject<[AssetMediaModel], Never> { get } -} - -class BaseMediasProvider: MediasProviderProtocol { - var selectionParamsHolder: SelectionParamsHolder - var filterClosure: MediaPicker.FilterClosure? - var massFilterClosure: MediaPicker.MassFilterClosure? - - @Binding var showingLoadingCell: Bool - - var assetMediaModelsPublisher = CurrentValueSubject<[AssetMediaModel], Never>([]) - - @Published var cancellableTask: Task? - - private var cancellable: AnyCancellable? - - init(selectionParamsHolder: SelectionParamsHolder, filterClosure: MediaPicker.FilterClosure?, massFilterClosure: MediaPicker.MassFilterClosure?, showingLoadingCell: Binding) { - self.selectionParamsHolder = selectionParamsHolder - self.filterClosure = filterClosure - self.massFilterClosure = massFilterClosure - self._showingLoadingCell = showingLoadingCell - - cancellable = photoLibraryChangeLimitedPhotosPublisher - .receive(on: RunLoop.main) - .sink { [weak self] in - self?.reload() - } - } - - func filterAndPublish(assets: [AssetMediaModel]) { - if let filterClosure = filterClosure { - showLoading(true) - cancellableTask = Task { - let serialQueue = DispatchQueue(label: "filterSerialQueue") - var result = [AssetMediaModel]() - await assets.asyncForEach { - if cancellableTask?.isCancelled ?? false { - return - } - if let media = await filterClosure(Media(source: $0)), let model = media.source as? AssetMediaModel { - serialQueue.sync { - result.append(model) - print(result.count) - assetMediaModelsPublisher.send(result) - } - } - } - showLoading(false) - } - } else if let massFilterClosure = massFilterClosure { - showLoading(true) - cancellableTask = Task { - let result = await massFilterClosure(assets.map { Media(source: $0) }) - assetMediaModelsPublisher.send(result.compactMap { $0.source as? AssetMediaModel }) - showLoading(false) - } - } - else { - DispatchQueue.main.async { [weak self] in - self?.assetMediaModelsPublisher.send(assets) - } - } - } - - func showLoading(_ show: Bool) { - DispatchQueue.main.async { [weak self] in - self?.$showingLoadingCell.wrappedValue = show - } - } - - func reload() { } - - func cancel() { - cancellableTask?.cancel() - } -} - -class MediasProvider { - - static func map(fetchResult: PHFetchResult, mediaSelectionType: MediaSelectionType) -> [AssetMediaModel] { - var assetMediaModels: [AssetMediaModel] = [] - - if fetchResult.count == 0 { - return assetMediaModels - } - - for index in 0...(fetchResult.count - 1) { - let asset = fetchResult[index] - if (asset.mediaType == .image && mediaSelectionType.allowsPhoto) || (asset.mediaType == .video && mediaSelectionType.allowsVideo) { - assetMediaModels.append(AssetMediaModel(asset: asset)) - } - } - return assetMediaModels - } -} diff --git a/Sources/ExyteMediaPicker/Drivers/PhotoKit/PhotoLibraryChangeNotifier.swift b/Sources/ExyteMediaPicker/Drivers/PhotoKit/PhotoLibraryChangeNotifier.swift deleted file mode 100644 index c28bced..0000000 --- a/Sources/ExyteMediaPicker/Drivers/PhotoKit/PhotoLibraryChangeNotifier.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// Created by Alex.M on 08.06.2022. -// - -import Foundation -import Combine -import Photos - -let photoLibraryChangePermissionNotification = Notification.Name(rawValue: "PhotoLibraryChangePermissionNotification") - -let photoLibraryChangeLimitedPhotosNotification = Notification.Name(rawValue: "PhotoLibraryChangeLimitedPhotosNotification") - -let photoLibraryChangePermissionPublisher = NotificationCenter.default - .publisher(for: photoLibraryChangePermissionNotification) - .map { _ in } - .share() - -let photoLibraryChangeLimitedPhotosPublisher = NotificationCenter.default - .publisher(for: photoLibraryChangeLimitedPhotosNotification) - .map { _ in } - .share() - -final class PhotoLibraryChangePermissionWatcher: NSObject, PHPhotoLibraryChangeObserver { - override init() { - super.init() - PHPhotoLibrary.shared().register(self) - } - - deinit { - PHPhotoLibrary.shared().unregisterChangeObserver(self) - } - - func photoLibraryDidChange(_ changeInstance: PHChange) { - // gets called too often, even if nothing changed - a bug? - NotificationCenter.default.post( - name: photoLibraryChangePermissionNotification, - object: nil) - } -} diff --git a/Sources/ExyteMediaPicker/Drivers/Selection/SelectionParamsHolder.swift b/Sources/ExyteMediaPicker/Drivers/Selection/SelectionParamsHolder.swift deleted file mode 100644 index 6b98d2f..0000000 --- a/Sources/ExyteMediaPicker/Drivers/Selection/SelectionParamsHolder.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// SelectionParamsHolder.swift -// -// -// Created by Alisa Mylnikova on 05.05.2023. -// - -import SwiftUI - -final class SelectionParamsHolder: ObservableObject { - - @Published var mediaType: MediaSelectionType = .photoAndVideo - @Published var selectionStyle: MediaSelectionStyle = .checkmark - @Published var selectionLimit: Int? // if nill - unlimited -} - -public enum MediaSelectionStyle { - case checkmark - case count -} - -public enum MediaSelectionType { - case photoAndVideo - case photo - case video - - var allowsPhoto: Bool { - [.photoAndVideo, .photo].contains(self) - } - - var allowsVideo: Bool { - [.photoAndVideo, .video].contains(self) - } -} diff --git a/Sources/ExyteMediaPicker/Drivers/Theme/MediaPickerTheme.swift b/Sources/ExyteMediaPicker/Drivers/Theme/MediaPickerTheme.swift deleted file mode 100644 index efb885f..0000000 --- a/Sources/ExyteMediaPicker/Drivers/Theme/MediaPickerTheme.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// Created by Alex.M on 06.07.2022. -// - -import Foundation -import SwiftUI - -public struct MediaPickerTheme { - public let main: Main - public let selection: Selection - public let error: Error - - public init(main: MediaPickerTheme.Main = .init(), - selection: MediaPickerTheme.Selection = .init(), - error: MediaPickerTheme.Error = .init()) { - self.main = main - self.selection = selection - self.error = error - } -} - -extension MediaPickerTheme { - public struct Main { - public let text: Color - public let albumSelectionBackground: Color - public let fullscreenPhotoBackground: Color - public let cameraBackground: Color - public let cameraSelectionBackground: Color - - public init(text: Color = Color(uiColor: .label), - albumSelectionBackground: Color = Color(uiColor: .systemGroupedBackground), - fullscreenPhotoBackground: Color = Color(uiColor: .systemGroupedBackground), - cameraBackground: Color = .black, - cameraSelectionBackground: Color = .black) { - self.text = text - self.albumSelectionBackground = albumSelectionBackground - self.fullscreenPhotoBackground = fullscreenPhotoBackground - self.cameraBackground = cameraBackground - self.cameraSelectionBackground = cameraSelectionBackground - } - } - - public struct Selection { - public let emptyTint: Color - public let emptyBackground: Color - public let selectedTint: Color - public let selectedBackground: Color - public let fullscreenTint: Color - - public init(emptyTint: Color = .white, - emptyBackground: Color = .clear, - selectedTint: Color = .blue, - selectedBackground: Color = .white, - fullscreenTint: Color = .blue) { - self.emptyTint = emptyTint - self.emptyBackground = emptyBackground - self.selectedTint = selectedTint - self.selectedBackground = selectedBackground - self.fullscreenTint = fullscreenTint - } - } - - public struct Error { - public let background: Color - public let tint: Color - - public init(background: Color = .red.opacity(0.7), - tint: Color = .white) { - self.background = background - self.tint = tint - } - } -} diff --git a/Sources/ExyteMediaPicker/Drivers/UIKit/UIKit+Config.swift b/Sources/ExyteMediaPicker/Drivers/UIKit/UIKit+Config.swift deleted file mode 100644 index da2b5be..0000000 --- a/Sources/ExyteMediaPicker/Drivers/UIKit/UIKit+Config.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// Created by Alex.M on 06.07.2022. -// - -import UIKit - -func setupNavigationBarAppearance() { - let appearance = UINavigationBarAppearance() - appearance.backgroundColor = .white - UINavigationBar.appearance().standardAppearance = appearance -} diff --git a/Sources/ExyteMediaPicker/Extensions/Set+Cancellable.swift b/Sources/ExyteMediaPicker/Extensions/Set+Cancellable.swift deleted file mode 100644 index 5472465..0000000 --- a/Sources/ExyteMediaPicker/Extensions/Set+Cancellable.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Created by Alex.M on 07.06.2022. -// - -import Foundation -import Combine - -extension Set where Element == AnyCancellable { - mutating func cancelAll() { - self = Set() - } -} diff --git a/Sources/ExyteMediaPicker/Extensions/View+NotificationCenter.swift b/Sources/ExyteMediaPicker/Extensions/View+NotificationCenter.swift deleted file mode 100644 index 048c15b..0000000 --- a/Sources/ExyteMediaPicker/Extensions/View+NotificationCenter.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// View+NotificationCenter.swift -// -// -// Created by Alexandra Afonasova on 17.10.2022. -// - -import SwiftUI - -extension View { - - func onEnteredBackground(perform: @escaping () -> Void) -> some View { - let publisher = NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification) - return onReceive(publisher) { _ in perform() } - } - - func onEnteredForeground(perform: @escaping () -> Void) -> some View { - let publisher = NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) - return onReceive(publisher) { _ in perform() } - } - - func onRotate(perform: @escaping (UIDeviceOrientation) -> Void) -> some View { - let publisher = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification) - return onReceive(publisher) { _ in perform(UIDevice.current.orientation) } - } - -} diff --git a/Sources/ExyteMediaPicker/Drivers/FileManager/FileManager.swift b/Sources/ExyteMediaPicker/Managers/FileManager.swift similarity index 100% rename from Sources/ExyteMediaPicker/Drivers/FileManager/FileManager.swift rename to Sources/ExyteMediaPicker/Managers/FileManager.swift diff --git a/Sources/ExyteMediaPicker/Drivers/PhotoKit/Medias/AlbumMediasProvider.swift b/Sources/ExyteMediaPicker/Managers/Medias/AlbumMediasProvider.swift similarity index 74% rename from Sources/ExyteMediaPicker/Drivers/PhotoKit/Medias/AlbumMediasProvider.swift rename to Sources/ExyteMediaPicker/Managers/Medias/AlbumMediasProvider.swift index aba4f69..29eed7e 100644 --- a/Sources/ExyteMediaPicker/Drivers/PhotoKit/Medias/AlbumMediasProvider.swift +++ b/Sources/ExyteMediaPicker/Managers/Medias/AlbumMediasProvider.swift @@ -3,7 +3,6 @@ // import Foundation -import Combine import Photos import SwiftUI @@ -11,25 +10,31 @@ final class AlbumMediasProvider: BaseMediasProvider { let album: AlbumModel - init(album: AlbumModel, selectionParamsHolder: SelectionParamsHolder, filterClosure: MediaPicker.FilterClosure? = nil, massFilterClosure: MediaPicker.MassFilterClosure? = nil, showingLoadingCell: Binding) { + init(album: AlbumModel, selectionParamsHolder: SelectionParamsHolder, filterClosure: MediaPicker.FilterClosure? = nil, massFilterClosure: MediaPicker.MassFilterClosure? = nil) { self.album = album - super.init(selectionParamsHolder: selectionParamsHolder, filterClosure: filterClosure, massFilterClosure: massFilterClosure, showingLoadingCell: showingLoadingCell) + super.init(selectionParamsHolder: selectionParamsHolder, filterClosure: filterClosure, massFilterClosure: massFilterClosure) } override func reload() { - PermissionsService.requestPermission { [ weak self] in - self?.reloadInternal() + PermissionsService.shared.requestPhotoLibraryPermission { + DispatchQueue.main.async { [weak self] in + self?.reloadInternal() + } } } func reloadInternal() { + isLoading = true + defer { + isLoading = false + } let fetchOptions = PHFetchOptions() fetchOptions.sortDescriptors = [ NSSortDescriptor(key: "creationDate", ascending: false) ] let fetchResult = PHAsset.fetchAssets(in: album.source, options: fetchOptions) if fetchResult.count == 0 { - assetMediaModelsPublisher.send([]) + assetMediaModels = [] } let assets = MediasProvider.map(fetchResult: fetchResult, mediaSelectionType: selectionParamsHolder.mediaType) diff --git a/Sources/ExyteMediaPicker/Managers/Medias/AllMediasProvider.swift b/Sources/ExyteMediaPicker/Managers/Medias/AllMediasProvider.swift new file mode 100644 index 0000000..b032b28 --- /dev/null +++ b/Sources/ExyteMediaPicker/Managers/Medias/AllMediasProvider.swift @@ -0,0 +1,34 @@ +// +// Created by Alex.M on 09.06.2022. +// + +import Foundation + +import Foundation +import Photos +import SwiftUI + +final class AllMediasProvider: BaseMediasProvider { + + override func reload() { + PermissionsService.shared.requestPhotoLibraryPermission { + DispatchQueue.main.async { [weak self] in + self?.reloadInternal() + } + } + } + + func reloadInternal() { + isLoading = true + defer { + isLoading = false + } + let allPhotosOptions = PHFetchOptions() + allPhotosOptions.sortDescriptors = [ + NSSortDescriptor(key: "creationDate", ascending: false) + ] + let allPhotos = PHAsset.fetchAssets(with: allPhotosOptions) + let assets = MediasProvider.map(fetchResult: allPhotos, mediaSelectionType: selectionParamsHolder.mediaType) + filterAndPublish(assets: assets) + } +} diff --git a/Sources/ExyteMediaPicker/Drivers/PhotoKit/Medias/AllPhotosProvider.swift b/Sources/ExyteMediaPicker/Managers/Medias/AllPhotosProvider.swift similarity index 77% rename from Sources/ExyteMediaPicker/Drivers/PhotoKit/Medias/AllPhotosProvider.swift rename to Sources/ExyteMediaPicker/Managers/Medias/AllPhotosProvider.swift index 0c609c1..64e90ba 100644 --- a/Sources/ExyteMediaPicker/Drivers/PhotoKit/Medias/AllPhotosProvider.swift +++ b/Sources/ExyteMediaPicker/Managers/Medias/AllPhotosProvider.swift @@ -6,14 +6,16 @@ import Foundation import Foundation import Photos -import Combine import SwiftUI +@MainActor final class AllPhotosProvider: BaseMediasProvider { override func reload() { - PermissionsService.requestPermission { [ weak self] in - self?.reloadInternal() + PermissionsService.shared.requestPhotoLibraryPermission { + DispatchQueue.main.async { [weak self] in + self?.reloadInternal() + } } } diff --git a/Sources/ExyteMediaPicker/Managers/Medias/BaseMediasProvider.swift b/Sources/ExyteMediaPicker/Managers/Medias/BaseMediasProvider.swift new file mode 100644 index 0000000..cacd9ff --- /dev/null +++ b/Sources/ExyteMediaPicker/Managers/Medias/BaseMediasProvider.swift @@ -0,0 +1,124 @@ +// +// Created by Alex.M on 09.06.2022. +// + +import Foundation +import Photos +import SwiftUI + +@MainActor +class BaseMediasProvider: ObservableObject { + var selectionParamsHolder: SelectionParamsHolder + var filterClosure: MediaPicker.FilterClosure? + var massFilterClosure: MediaPicker.MassFilterClosure? + + @Published var assetMediaModels = [AssetMediaModel]() + private var privateAssetMediaModels: [AssetMediaModel] = [] + + @Published var isLoading: Bool = false + + private var timerTask: Task? + private var cancellableTask: Task? + + init(selectionParamsHolder: SelectionParamsHolder, filterClosure: MediaPicker.FilterClosure?, massFilterClosure: MediaPicker.MassFilterClosure?) { + self.selectionParamsHolder = selectionParamsHolder + self.filterClosure = filterClosure + self.massFilterClosure = massFilterClosure + } + + func filterAndPublish(assets: [AssetMediaModel]) { + isLoading = true + defer { + isLoading = false + } + + if let filterClosure = filterClosure { + startPublishing() + + cancellableTask = Task { [weak self] in + let serialQueue = DispatchQueue(label: "filterSerialQueue") + self?.privateAssetMediaModels = [AssetMediaModel]() + + await withTaskGroup(of: AssetMediaModel?.self) { group in + for asset in assets { + group.addTask { + if Task.isCancelled { return nil } + + let media = await Task.detached(priority: .userInitiated) { + return await filterClosure(Media(source: asset)) + }.value + + return media?.source as? AssetMediaModel + } + } + + for await filteredMedia in group { + if let model = filteredMedia { + serialQueue.sync { + self?.privateAssetMediaModels.append(model) + } + } + } + } + + self?.stopPublishing() + DispatchQueue.main.async { + self?.assetMediaModels = self?.privateAssetMediaModels ?? [] + } + } + } else if let massFilterClosure = massFilterClosure { + cancellableTask = Task { [weak self] in + let result = await massFilterClosure(assets.map { Media(source: $0) }) + self?.assetMediaModels = result.compactMap { $0.source as? AssetMediaModel } + } + } + else { + DispatchQueue.main.async { [weak self] in + self?.assetMediaModels = assets + } + } + } + + func startPublishing() { + // Start a task that runs every second + timerTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second + + await MainActor.run { + self.assetMediaModels = self.privateAssetMediaModels + } + } + } + } + + func stopPublishing() { + timerTask?.cancel() + } + + func reload() { } + + func cancel() { + cancellableTask?.cancel() + stopPublishing() + } +} + +class MediasProvider { + + static func map(fetchResult: PHFetchResult, mediaSelectionType: MediaSelectionType) -> [AssetMediaModel] { + var assetMediaModels: [AssetMediaModel] = [] + + if fetchResult.count == 0 { + return assetMediaModels + } + + for index in 0...(fetchResult.count - 1) { + let asset = fetchResult[index] + if (asset.mediaType == .image && mediaSelectionType.allowsPhoto) || (asset.mediaType == .video && mediaSelectionType.allowsVideo) { + assetMediaModels.append(AssetMediaModel(asset: asset)) + } + } + return assetMediaModels + } +} diff --git a/Sources/ExyteMediaPicker/Managers/Medias/DefaultAlbumsProvider.swift b/Sources/ExyteMediaPicker/Managers/Medias/DefaultAlbumsProvider.swift new file mode 100644 index 0000000..1d405b8 --- /dev/null +++ b/Sources/ExyteMediaPicker/Managers/Medias/DefaultAlbumsProvider.swift @@ -0,0 +1,88 @@ +// +// Created by Alex.M on 10.06.2022. +// + +import Foundation +import Photos + +import Photos +import SwiftUI + +@MainActor +final class DefaultAlbumsProvider: ObservableObject { + + @Published private(set) var albums: [AlbumModel] = [] + @Published private(set) var isLoading: Bool = false + + var mediaSelectionType: MediaSelectionType = .photoAndVideo + private var reloadTask: Task? + + func reload() { + cancelReload() + + PermissionsService.shared.requestPhotoLibraryPermission { [weak self] in + guard let self = self else { return } + DispatchQueue.main.async { + self.reloadTask = Task { + self.isLoading = true + await self.reloadInternal() + self.isLoading = false + } + } + } + } + + func cancelReload() { + reloadTask?.cancel() + reloadTask = nil + } + + private func reloadInternal() async { + let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum] + var allAlbums: [AlbumModel] = [] + + for type in albumTypes { + if Task.isCancelled { return } + let albums = fetchAlbums(type: type) + allAlbums.append(contentsOf: albums) + } + + if Task.isCancelled { return } + self.albums = allAlbums + } + + private func fetchAlbums(type: PHAssetCollectionType) -> [AlbumModel] { + let options = PHFetchOptions() + options.includeAssetSourceTypes = [.typeUserLibrary, .typeiTunesSynced, .typeCloudShared] + options.sortDescriptors = [NSSortDescriptor(key: "localizedTitle", ascending: true)] + + let collections = PHAssetCollection.fetchAssetCollections( + with: type, + subtype: .any, + options: options + ) + + guard collections.count > 0 else { return [] } + + var albums: [AlbumModel] = [] + + for index in 0.. 0 else { continue } + + let preview = MediasProvider.map(fetchResult: fetchResult, mediaSelectionType: mediaSelectionType).first + let album = AlbumModel(preview: preview, source: collection) + albums.append(album) + } + return albums + } +} diff --git a/Sources/ExyteMediaPicker/Drivers/MotionManager/MotionManager.swift b/Sources/ExyteMediaPicker/Managers/MotionManager.swift similarity index 100% rename from Sources/ExyteMediaPicker/Drivers/MotionManager/MotionManager.swift rename to Sources/ExyteMediaPicker/Managers/MotionManager.swift diff --git a/Sources/ExyteMediaPicker/Managers/PermissionsService.swift b/Sources/ExyteMediaPicker/Managers/PermissionsService.swift new file mode 100644 index 0000000..9e35378 --- /dev/null +++ b/Sources/ExyteMediaPicker/Managers/PermissionsService.swift @@ -0,0 +1,101 @@ +// +// Created by Alex.M on 08.06.2022. +// + +import Foundation +import Combine +import AVFoundation +import Photos + +@MainActor +final class PermissionsService: ObservableObject { + + static var shared = PermissionsService() + + @Published var cameraPermissionStatus: CameraPermissionStatus = .unknown + @Published var photoLibraryPermissionStatus: PhotoLibraryPermissionStatus = .unknown + + /// 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 + func requestPhotoLibraryPermission(_ permissionGrantedClosure: @Sendable @escaping ()->()) { + Task { + let currentStatus = PHPhotoLibrary.authorizationStatus(for: .addOnly) + if currentStatus == .authorized || currentStatus == .limited { + permissionGrantedClosure() + return + } + + let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly) + updatePhotoLibraryAuthorizationStatus() + if status == .authorized || status == .limited { + permissionGrantedClosure() + } + } + } + + func requestCameraPermission() { + Task { + await AVCaptureDevice.requestAccess(for: .video) + updateCameraAuthorizationStatus() + } + } + + func updatePhotoLibraryAuthorizationStatus() { + let status = PHPhotoLibrary.authorizationStatus(for: .addOnly) + + let result: PhotoLibraryPermissionStatus + switch status { + case .authorized: + result = .authorized + case .limited: + result = .limited + case .restricted, .denied: + result = .unavailable + case .notDetermined: + result = .unknown + default: + result = .unknown + } + + DispatchQueue.main.async { [weak self] in + self?.photoLibraryPermissionStatus = result + } + } + + func updateCameraAuthorizationStatus() { + let status = AVCaptureDevice.authorizationStatus(for: .video) + + let result: CameraPermissionStatus +#if targetEnvironment(simulator) + result = .unavailable +#else + switch status { + case .authorized: + result = .authorized + case .restricted, .denied: + result = .unavailable + case .notDetermined: + result = .unknown + default: + result = .unknown + } +#endif + DispatchQueue.main.async { [weak self] in + self?.cameraPermissionStatus = result + } + } +} + +extension PermissionsService { + enum CameraPermissionStatus { + case authorized + case unavailable + case unknown + } + + enum PhotoLibraryPermissionStatus { + case limited + case authorized + case unavailable + case unknown + } +} diff --git a/Sources/ExyteMediaPicker/Drivers/PhotoKit/PHAsset+Utils.swift b/Sources/ExyteMediaPicker/Managers/PhotoKit/PHAsset+Utils.swift similarity index 83% rename from Sources/ExyteMediaPicker/Drivers/PhotoKit/PHAsset+Utils.swift rename to Sources/ExyteMediaPicker/Managers/PhotoKit/PHAsset+Utils.swift index 031e08e..aad3265 100644 --- a/Sources/ExyteMediaPicker/Drivers/PhotoKit/PHAsset+Utils.swift +++ b/Sources/ExyteMediaPicker/Managers/PhotoKit/PHAsset+Utils.swift @@ -41,6 +41,7 @@ extension PHAsset { if mediaType == .image { let options = PHContentEditingInputRequestOptions() + options.isNetworkAccessAllowed = true options.canHandleAdjustmentData = { _ -> Bool in return true } @@ -101,28 +102,33 @@ extension PHAsset { func getThumbnailURL() async -> URL? { guard let url = await getURL() else { return nil } - if mediaType == .image { - return url - } - - let asset: AVAsset = AVAsset(url: url) - if let thumbnailData = asset.generateThumbnail() { + switch mediaType { + case .image: + guard let image = UIImage.from(url: url), + let thumbnailData = image.generateThumbnail() + else { return nil } + return FileManager.storeToTempDir(data: thumbnailData) + case .video: + let asset = AVAsset(url: url) + guard let thumbnailData = asset.generateThumbnail() else { return nil } return FileManager.storeToTempDir(data: thumbnailData) + default: return nil } - - return nil } func getThumbnailData() async -> Data? { - if mediaType == .image { - return try? await self.getData() - } - else if mediaType == .video { + switch mediaType { + case .image: + guard let data = try? await getData(), + let image = UIImage(data: data) + else { return nil } + return image.generateThumbnail() + case .video: guard let url = await getURL() else { return nil } let asset: AVAsset = AVAsset(url: url) return asset.generateThumbnail() + default: return nil } - return nil } } @@ -139,6 +145,7 @@ extension CGImage { #if os(iOS) extension PHAsset { + @MainActor func image(size: CGSize, resultClosure: @escaping (UIImage?)->()) -> PHImageRequestID { let requestSize = CGSize(width: size.width * UIScreen.main.scale, height: size.height * UIScreen.main.scale) @@ -217,6 +224,32 @@ extension AVAsset { } } +extension UIImage { + + enum ThumbnailQuality { + case low + case medium + case high + + var compressionValue: CGFloat { + switch self { + case .low: return 0.3 + case .medium: return 0.6 + case .high: return 0.8 + } + } + } + + static func from(url: URL) -> UIImage? { + guard let data = try? Data(contentsOf: url) else { return nil } + return UIImage(data: data) + } + + func generateThumbnail(quality: ThumbnailQuality = .medium) -> Data? { + jpegData(compressionQuality: quality.compressionValue) + } +} + enum AssetFetchError: Error { case noImageData case unknownType diff --git a/Sources/ExyteMediaPicker/Drivers/PhotoKit/URL+Utils.swift b/Sources/ExyteMediaPicker/Managers/PhotoKit/URL+Utils.swift similarity index 100% rename from Sources/ExyteMediaPicker/Drivers/PhotoKit/URL+Utils.swift rename to Sources/ExyteMediaPicker/Managers/PhotoKit/URL+Utils.swift diff --git a/Sources/ExyteMediaPicker/Drivers/Selection/CameraSelectionService.swift b/Sources/ExyteMediaPicker/Managers/Selection/CameraSelectionService.swift similarity index 57% rename from Sources/ExyteMediaPicker/Drivers/Selection/CameraSelectionService.swift rename to Sources/ExyteMediaPicker/Managers/Selection/CameraSelectionService.swift index 6a46dc2..3ffb547 100644 --- a/Sources/ExyteMediaPicker/Drivers/Selection/CameraSelectionService.swift +++ b/Sources/ExyteMediaPicker/Managers/Selection/CameraSelectionService.swift @@ -12,6 +12,7 @@ final class CameraSelectionService: ObservableObject { var mediaSelectionLimit: Int? // if nill - unlimited var onChange: MediaPickerCompletionClosure? = nil + @Published private(set) var added: [URLMediaModel] = [] @Published private(set) var selected: [URLMediaModel] = [] var hasSelected: Bool { @@ -30,9 +31,14 @@ final class CameraSelectionService: ObservableObject { } func onSelect(media: URLMediaModel) { - if let index = selected.firstIndex(of: media) { - selected.remove(at: index) + if added.contains(media) { + if let index = selected.firstIndex(of: media) { + selected.remove(at: index) + } else if fitsSelectionLimit { + selected.append(media) + } } else { + added.append(media) if fitsSelectionLimit { selected.append(media) } @@ -40,8 +46,26 @@ final class CameraSelectionService: ObservableObject { onChange?(mapToMedia()) } - func index(of media: URLMediaModel) -> Int? { - selected.firstIndex(of: media) + func onSelect(index: Int) { + guard added.indices.contains(index) else { return } + let media = added[index] + if let index = selected.firstIndex(of: media) { + selected.remove(at: index) + } else if fitsSelectionLimit { + selected.append(media) + } + onChange?(mapToMedia()) + } + + func isSelected(index: Int) -> Bool { + guard added.indices.contains(index) else { return false } + return selected.contains(added[index]) + } + + func selectedIndex(fromAddedIndex index: Int) -> Int? { + guard added.indices.contains(index) else { return nil } + let media = added[index] + return selected.firstIndex(of: media) } func mapToMedia() -> [Media] { @@ -56,6 +80,7 @@ final class CameraSelectionService: ObservableObject { func removeAll() { selected.removeAll() + added.removeAll() onChange?([]) } } diff --git a/Sources/ExyteMediaPicker/Managers/Selection/SelectionParamsHolder.swift b/Sources/ExyteMediaPicker/Managers/Selection/SelectionParamsHolder.swift new file mode 100644 index 0000000..552cfe5 --- /dev/null +++ b/Sources/ExyteMediaPicker/Managers/Selection/SelectionParamsHolder.swift @@ -0,0 +1,42 @@ +// +// SelectionParamsHolder.swift +// +// +// Created by Alisa Mylnikova on 05.05.2023. +// + +import SwiftUI + +final public class SelectionParamsHolder: ObservableObject { + + @Published public var mediaType: MediaSelectionType = .photoAndVideo + @Published public var selectionStyle: MediaSelectionStyle = .checkmark + @Published public var selectionLimit: Int? // if nil - unlimited + @Published public var showFullscreenPreview: Bool = true // if false, tap on image immediately selects this image and closes the picker + + public init(mediaType: MediaSelectionType = .photoAndVideo, selectionStyle: MediaSelectionStyle = .checkmark, selectionLimit: Int? = nil, showFullscreenPreview: Bool = true) { + self.mediaType = mediaType + self.selectionStyle = selectionStyle + self.selectionLimit = selectionLimit + self.showFullscreenPreview = showFullscreenPreview + } +} + +public enum MediaSelectionStyle { + case checkmark + case count +} + +public enum MediaSelectionType { + case photoAndVideo + case photo + case video + + var allowsPhoto: Bool { + [.photoAndVideo, .photo].contains(self) + } + + var allowsVideo: Bool { + [.photoAndVideo, .video].contains(self) + } +} diff --git a/Sources/ExyteMediaPicker/Drivers/Selection/SelectionService.swift b/Sources/ExyteMediaPicker/Managers/Selection/SelectionService.swift similarity index 73% rename from Sources/ExyteMediaPicker/Drivers/Selection/SelectionService.swift rename to Sources/ExyteMediaPicker/Managers/Selection/SelectionService.swift index cffcead..7a73669 100644 --- a/Sources/ExyteMediaPicker/Drivers/Selection/SelectionService.swift +++ b/Sources/ExyteMediaPicker/Managers/Selection/SelectionService.swift @@ -4,7 +4,6 @@ import Foundation import SwiftUI -import Combine import Photos final class SelectionService: ObservableObject { @@ -65,23 +64,3 @@ final class SelectionService: ObservableObject { } } } - -private extension SelectionService { - - func findAsset(identifier: String) -> Future { - Future { promise in - let options = PHFetchOptions() - let photos = PHAsset.fetchAssets(with: options) - - var result: PHAsset? - - photos.enumerateObjects { (asset, _, stop) in - if asset.localIdentifier == identifier { - result = asset - stop.pointee = true - } - } - promise(.success(result)) - } - } -} diff --git a/Sources/ExyteMediaPicker/Data/AlbumModel.swift b/Sources/ExyteMediaPicker/Model/AlbumModel.swift similarity index 100% rename from Sources/ExyteMediaPicker/Data/AlbumModel.swift rename to Sources/ExyteMediaPicker/Model/AlbumModel.swift diff --git a/Sources/ExyteMediaPicker/Data/AssetMediaModel.swift b/Sources/ExyteMediaPicker/Model/AssetMediaModel.swift similarity index 100% rename from Sources/ExyteMediaPicker/Data/AssetMediaModel.swift rename to Sources/ExyteMediaPicker/Model/AssetMediaModel.swift diff --git a/Sources/ExyteMediaPicker/Data/Media.swift b/Sources/ExyteMediaPicker/Model/Media.swift similarity index 74% rename from Sources/ExyteMediaPicker/Data/Media.swift rename to Sources/ExyteMediaPicker/Model/Media.swift index 417ed80..017f09c 100644 --- a/Sources/ExyteMediaPicker/Data/Media.swift +++ b/Sources/ExyteMediaPicker/Model/Media.swift @@ -3,16 +3,19 @@ // import Foundation -import Combine public enum MediaType { case image case video } -public struct Media: Identifiable, Equatable { +public struct Media: Identifiable, Equatable, Sendable { public var id = UUID() - internal let source: MediaModelProtocol + public let source: MediaModelProtocol + + public init(source: MediaModelProtocol) { + self.source = source + } public static func == (lhs: Media, rhs: Media) -> Bool { lhs.id == rhs.id @@ -26,7 +29,9 @@ public extension Media { } var duration: CGFloat? { - source.duration + get async { + await source.duration + } } func getURL() async -> URL? { diff --git a/Sources/ExyteMediaPicker/Data/MediaModelProtocol.swift b/Sources/ExyteMediaPicker/Model/MediaModelProtocol.swift similarity index 77% rename from Sources/ExyteMediaPicker/Data/MediaModelProtocol.swift rename to Sources/ExyteMediaPicker/Model/MediaModelProtocol.swift index 0e526c9..bcea99f 100644 --- a/Sources/ExyteMediaPicker/Data/MediaModelProtocol.swift +++ b/Sources/ExyteMediaPicker/Model/MediaModelProtocol.swift @@ -7,9 +7,9 @@ import SwiftUI -protocol MediaModelProtocol { +public protocol MediaModelProtocol: Sendable { var mediaType: MediaType? { get } - var duration: CGFloat? { get } + var duration: CGFloat? { get async } func getURL() async -> URL? func getThumbnailURL() async -> URL? diff --git a/Sources/ExyteMediaPicker/Data/Types.swift b/Sources/ExyteMediaPicker/Model/Types.swift similarity index 100% rename from Sources/ExyteMediaPicker/Data/Types.swift rename to Sources/ExyteMediaPicker/Model/Types.swift diff --git a/Sources/ExyteMediaPicker/Data/URLMediaModel.swift b/Sources/ExyteMediaPicker/Model/URLMediaModel.swift similarity index 77% rename from Sources/ExyteMediaPicker/Data/URLMediaModel.swift rename to Sources/ExyteMediaPicker/Model/URLMediaModel.swift index 85aa126..5586ca0 100644 --- a/Sources/ExyteMediaPicker/Data/URLMediaModel.swift +++ b/Sources/ExyteMediaPicker/Model/URLMediaModel.swift @@ -26,7 +26,15 @@ extension URLMediaModel: MediaModelProtocol { } var duration: CGFloat? { - CMTimeGetSeconds(AVURLAsset(url: url).duration) + get async { + let asset = AVURLAsset(url: url) + do { + let duration = try await asset.load(.duration) + return CGFloat(CMTimeGetSeconds(duration)) + } catch { + return nil + } + } } func getURL() async -> URL? { @@ -36,7 +44,7 @@ extension URLMediaModel: MediaModelProtocol { func getThumbnailURL() async -> URL? { switch mediaType { case .image: - return url + return await url.getThumbnailURL() case .video: return await url.getThumbnailURL() case .none: @@ -51,7 +59,7 @@ extension URLMediaModel: MediaModelProtocol { func getThumbnailData() async -> Data? { switch mediaType { case .image: - return try? Data(contentsOf: url) + return await url.getThumbnailData() case .video: return await url.getThumbnailData() case .none: diff --git a/Sources/ExyteMediaPicker/Modifiers/MediaPickerThemeModifier.swift b/Sources/ExyteMediaPicker/Modifiers/MediaPickerThemeModifier.swift deleted file mode 100644 index 9935246..0000000 --- a/Sources/ExyteMediaPicker/Modifiers/MediaPickerThemeModifier.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// Created by Alex.M on 06.07.2022. -// - -import Foundation -import SwiftUI - -struct MediaPickerThemeKey: EnvironmentKey { - static var defaultValue: MediaPickerTheme = MediaPickerTheme() -} - -extension EnvironmentValues { - public var mediaPickerTheme: MediaPickerTheme { - get { self[MediaPickerThemeKey.self] } - set { self[MediaPickerThemeKey.self] = newValue } - } -} - -public extension View { - func mediaPickerTheme(_ theme: MediaPickerTheme) -> some View { - self.environment(\.mediaPickerTheme, theme) - } - - func mediaPickerTheme(main: MediaPickerTheme.Main = .init(), - selection: MediaPickerTheme.Selection = .init(), - error: MediaPickerTheme.Error = .init()) -> some View { - self.environment(\.mediaPickerTheme, MediaPickerTheme(main: main, selection: selection, error: error)) - } -} diff --git a/Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/Contents.json b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/cameraBG.colorset/Contents.json b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/cameraBG.colorset/Contents.json new file mode 100644 index 0000000..be9d677 --- /dev/null +++ b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/cameraBG.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/cameraText.colorset/Contents.json b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/cameraText.colorset/Contents.json new file mode 100644 index 0000000..951b907 --- /dev/null +++ b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/cameraText.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFE" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFE" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/pickerBG.colorset/Contents.json b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/pickerBG.colorset/Contents.json new file mode 100644 index 0000000..9c0e331 --- /dev/null +++ b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/pickerBG.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/pickerText.colorset/Contents.json b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/pickerText.colorset/Contents.json new file mode 100644 index 0000000..3fe9b59 --- /dev/null +++ b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/pickerText.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFE" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/selection.colorset/Contents.json b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/selection.colorset/Contents.json new file mode 100644 index 0000000..7e19bd5 --- /dev/null +++ b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Colors/selection.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0x71", + "red" : "0x5A" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0x71", + "red" : "0x5A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/Contents.json b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/ExyteMediaPicker/Resources/Media.xcassets/FlashOff.imageset/Contents.json b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/FlashOff.imageset/Contents.json similarity index 100% rename from Sources/ExyteMediaPicker/Resources/Media.xcassets/FlashOff.imageset/Contents.json rename to Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/FlashOff.imageset/Contents.json diff --git a/Sources/ExyteMediaPicker/Resources/Media.xcassets/FlashOff.imageset/Flash.pdf b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/FlashOff.imageset/Flash.pdf similarity index 100% rename from Sources/ExyteMediaPicker/Resources/Media.xcassets/FlashOff.imageset/Flash.pdf rename to Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/FlashOff.imageset/Flash.pdf diff --git a/Sources/ExyteMediaPicker/Resources/Media.xcassets/FlashOn.imageset/Contents.json b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/FlashOn.imageset/Contents.json similarity index 100% rename from Sources/ExyteMediaPicker/Resources/Media.xcassets/FlashOn.imageset/Contents.json rename to Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/FlashOn.imageset/Contents.json diff --git a/Sources/ExyteMediaPicker/Resources/Media.xcassets/FlashOn.imageset/Flash on.pdf b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/FlashOn.imageset/Flash on.pdf similarity index 100% rename from Sources/ExyteMediaPicker/Resources/Media.xcassets/FlashOn.imageset/Flash on.pdf rename to Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/FlashOn.imageset/Flash on.pdf diff --git a/Sources/ExyteMediaPicker/Resources/Media.xcassets/FlipCamera.imageset/Change Camera.pdf b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/FlipCamera.imageset/Change Camera.pdf similarity index 100% rename from Sources/ExyteMediaPicker/Resources/Media.xcassets/FlipCamera.imageset/Change Camera.pdf rename to Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/FlipCamera.imageset/Change Camera.pdf diff --git a/Sources/ExyteMediaPicker/Resources/Media.xcassets/FlipCamera.imageset/Contents.json b/Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/FlipCamera.imageset/Contents.json similarity index 100% rename from Sources/ExyteMediaPicker/Resources/Media.xcassets/FlipCamera.imageset/Contents.json rename to Sources/ExyteMediaPicker/Resources/Media.xcassets/Images/FlipCamera.imageset/Contents.json diff --git a/Sources/ExyteMediaPicker/Screens/AlbumView/AlbumView.swift b/Sources/ExyteMediaPicker/Screens/AlbumView/AlbumView.swift new file mode 100644 index 0000000..10b910d --- /dev/null +++ b/Sources/ExyteMediaPicker/Screens/AlbumView/AlbumView.swift @@ -0,0 +1,169 @@ +// +// Created by Alex.M on 27.05.2022. +// + +public enum LiveCameraCellStyle { + case none + case small // 1 cell in photos grid + case prominant // 2 cell height +} + +import SwiftUI +import AnchoredPopup + +struct AlbumView: View { + + @EnvironmentObject private var selectionService: SelectionService + @Environment(\.mediaPickerTheme) private var theme + + @ObservedObject var keyboardHeightHelper = KeyboardHeightHelper.shared + @ObservedObject var permissionsService = PermissionsService.shared + + @StateObject var viewModel: BaseMediasProvider + @Binding var showingCamera: Bool + @Binding var currentFullscreenMedia: Media? + + var selectionParamsHolder: SelectionParamsHolder + var mediaPickerParamsHolder: MediaPickerParamsHolder + var dismiss: ()->() + + @State private var fullscreenItem: AssetMediaModel.ID? + + private var shouldShowLoadingCell: Bool { + viewModel.isLoading && viewModel.assetMediaModels.count > 0 + } + + var body: some View { + content + .onAppear { + viewModel.reload() + } + .onDisappear { + viewModel.cancel() + } + } + + @ViewBuilder + var content: some View { + ScrollView { + VStack(spacing: 0) { + PermissionActionView(type: .library(permissionsService.photoLibraryPermissionStatus)) + + if mediaPickerParamsHolder.liveCameraCell != .none { + PermissionActionView(type: .camera(permissionsService.cameraPermissionStatus)) + } + + if viewModel.isLoading, viewModel.assetMediaModels.isEmpty { + ProgressView() + .padding() + } else if !viewModel.isLoading, viewModel.assetMediaModels.isEmpty { + Text("Empty data") + .font(.title3) + .foregroundColor(theme.main.pickerText) + } else { + mediasGrid + } + + Spacer() + } + .frame(maxWidth: .infinity) + } + .background(theme.main.pickerBackground) + .onTapGesture { + if keyboardHeightHelper.keyboardDisplayed { + dismissKeyboard() + } + } + } + + private func getLiveCameraCell() -> LiveCameraCellStyle { + #if targetEnvironment(simulator) + return .none + #else + return if permissionsService.cameraPermissionStatus != .authorized { + .none + } else { + mediaPickerParamsHolder.liveCameraCell + } + #endif + } + + var mediasGrid: some View { + MediasGrid(viewModel.assetMediaModels, liveCameraCell: getLiveCameraCell()) { +#if !targetEnvironment(simulator) + if getLiveCameraCell() != .none && permissionsService.cameraPermissionStatus == .authorized { + LiveCameraCell { + showingCamera = true + } + } +#endif + } content: { assetMediaModel, index, cellSize in + cellView(assetMediaModel, index, cellSize) + } loadingCell: { + if shouldShowLoadingCell { + ZStack { + Color.white.opacity(0.5) + ProgressView() + } + .aspectRatio(1, contentMode: .fit) + } + } + .onChange(of: viewModel.assetMediaModels) { _ , newValue in + selectionService.updateSelection(with: newValue) + } + } + + @ViewBuilder + func cellView(_ assetMediaModel: AssetMediaModel, _ index: Int, _ size: CGFloat) -> some View { + let imageButton = Button { + if keyboardHeightHelper.keyboardDisplayed { + dismissKeyboard() + } + if !selectionParamsHolder.showFullscreenPreview { // select immediately + selectionService.onSelect(assetMediaModel: assetMediaModel) + if selectionService.mediaSelectionLimit == 1 { + dismiss() + } + } + else if fullscreenItem == nil { + fullscreenItem = assetMediaModel.id + } + } label: { + let id = "fullscreen_photo_\(index)" + MediaCell(viewModel: MediaViewModel(assetMediaModel: assetMediaModel), size: size) + .applyIf(selectionParamsHolder.showFullscreenPreview) { + $0.useAsPopupAnchor(id: id) { + FullscreenContainer( + currentFullscreenMedia: $currentFullscreenMedia, + selection: $fullscreenItem, + animationID: id, + assetMediaModels: viewModel.assetMediaModels, + selectionParamsHolder: selectionParamsHolder, + dismiss: dismiss + ) + .environmentObject(selectionService) + } customize: { + $0.closeOnTap(false) + .animation(.easeIn(duration: 0.2)) + } + .simultaneousGesture( + TapGesture().onEnded { + fullscreenItem = assetMediaModel.id + } + ) + } + } + .buttonStyle(MediaButtonStyle()) + .contentShape(Rectangle()) + + if selectionService.mediaSelectionLimit == 1 { + imageButton + } else { + SelectableView(selected: selectionService.index(of: assetMediaModel), isFullscreen: false, canSelect: selectionService.canSelect(assetMediaModel: assetMediaModel), selectionParamsHolder: selectionParamsHolder) { + selectionService.onSelect(assetMediaModel: assetMediaModel) + } content: { + imageButton + } + } + } +} diff --git a/Sources/ExyteMediaPicker/Views/Pages/AlbumView/MediaCell.swift b/Sources/ExyteMediaPicker/Screens/AlbumView/MediaCell.swift similarity index 71% rename from Sources/ExyteMediaPicker/Views/Pages/AlbumView/MediaCell.swift rename to Sources/ExyteMediaPicker/Screens/AlbumView/MediaCell.swift index 3f4e169..a066de2 100644 --- a/Sources/ExyteMediaPicker/Views/Pages/AlbumView/MediaCell.swift +++ b/Sources/ExyteMediaPicker/Screens/AlbumView/MediaCell.swift @@ -6,19 +6,21 @@ import SwiftUI struct MediaCell: View { + @Environment(\.mediaPickerTheme) private var theme + @StateObject var viewModel: MediaViewModel - + var size: CGFloat + var body: some View { ZStack { - GeometryReader { geometry in - ThumbnailView(preview: viewModel.preview) - .onAppear { - viewModel.onStart(size: geometry.size) - } - } - .aspectRatio(1, contentMode: .fill) - .clipped() - + ThumbnailView(preview: viewModel.preview, size: size) + .cornerRadius(theme.cellStyle.cornerRadius) + .onAppear { + viewModel.onStart(size: size) + } + .aspectRatio(1, contentMode: .fill) + .clipped() + if let duration = viewModel.assetMediaModel.asset.formattedDuration { VStack { Spacer() diff --git a/Sources/ExyteMediaPicker/Views/Pages/AlbumView/MediaViewModel.swift b/Sources/ExyteMediaPicker/Screens/AlbumView/MediaViewModel.swift similarity index 76% rename from Sources/ExyteMediaPicker/Views/Pages/AlbumView/MediaViewModel.swift rename to Sources/ExyteMediaPicker/Screens/AlbumView/MediaViewModel.swift index fb62e9e..3f8c3e4 100644 --- a/Sources/ExyteMediaPicker/Views/Pages/AlbumView/MediaViewModel.swift +++ b/Sources/ExyteMediaPicker/Screens/AlbumView/MediaViewModel.swift @@ -22,10 +22,12 @@ class MediaViewModel: ObservableObject { // FIXME: Create preview for image/video for other platforms #endif - func onStart(size: CGSize) { + @MainActor func onStart(size: CGFloat) { requestID = assetMediaModel.asset - .image(size: size) { - self.preview = $0 + .image(size: CGSize(width: size, height: size)) { image in + DispatchQueue.main.async { + self.preview = image + } } } diff --git a/Sources/ExyteMediaPicker/Views/Pages/AlbumsView/AlbumCell.swift b/Sources/ExyteMediaPicker/Screens/AlbumsView/AlbumCell.swift similarity index 60% rename from Sources/ExyteMediaPicker/Views/Pages/AlbumsView/AlbumCell.swift rename to Sources/ExyteMediaPicker/Screens/AlbumsView/AlbumCell.swift index 9ec23db..d0acb28 100644 --- a/Sources/ExyteMediaPicker/Views/Pages/AlbumsView/AlbumCell.swift +++ b/Sources/ExyteMediaPicker/Screens/AlbumsView/AlbumCell.swift @@ -6,30 +6,29 @@ import SwiftUI struct AlbumCell: View { + @Environment(\.mediaPickerTheme) private var theme + @StateObject var viewModel: AlbumCellViewModel + var size: CGFloat - @Environment(\.mediaPickerTheme) private var theme - var body: some View { VStack { Rectangle() .aspectRatio(1, contentMode: .fit) .overlay { - GeometryReader { geometry in - ThumbnailView(preview: viewModel.preview) - .onAppear { - viewModel.fetchPreview(size: geometry.size) - } - } + ThumbnailView(preview: viewModel.preview, size: size) + .onAppear { + viewModel.fetchPreview(size: CGSize(width: size, height: size)) + } } .clipped() - .foregroundColor(theme.main.albumSelectionBackground) - + .foregroundColor(theme.main.pickerBackground) + if let title = viewModel.album.title { Text(title) .lineLimit(2) .multilineTextAlignment(.center) - .foregroundColor(theme.main.text) + .foregroundColor(theme.main.pickerText) } } .onDisappear { diff --git a/Sources/ExyteMediaPicker/Views/Pages/AlbumsView/AlbumCellViewModel.swift b/Sources/ExyteMediaPicker/Screens/AlbumsView/AlbumCellViewModel.swift similarity index 77% rename from Sources/ExyteMediaPicker/Views/Pages/AlbumsView/AlbumCellViewModel.swift rename to Sources/ExyteMediaPicker/Screens/AlbumsView/AlbumCellViewModel.swift index 16fcfd5..c42102f 100644 --- a/Sources/ExyteMediaPicker/Views/Pages/AlbumsView/AlbumCellViewModel.swift +++ b/Sources/ExyteMediaPicker/Screens/AlbumsView/AlbumCellViewModel.swift @@ -22,12 +22,14 @@ class AlbumCellViewModel: ObservableObject { // FIXME: Create preview for image/video for other platforms #endif - func fetchPreview(size: CGSize) { + @MainActor func fetchPreview(size: CGSize) { guard preview == nil else { return } requestID = album.preview?.asset - .image(size: size) { [weak self] in - self?.preview = $0 + .image(size: size) { [weak self] image in + DispatchQueue.main.async { + self?.preview = image + } } } diff --git a/Sources/ExyteMediaPicker/Views/Pages/AlbumsView/AlbumsView.swift b/Sources/ExyteMediaPicker/Screens/AlbumsView/AlbumsView.swift similarity index 63% rename from Sources/ExyteMediaPicker/Views/Pages/AlbumsView/AlbumsView.swift rename to Sources/ExyteMediaPicker/Screens/AlbumsView/AlbumsView.swift index 1c6e090..ddd4c0f 100644 --- a/Sources/ExyteMediaPicker/Views/Pages/AlbumsView/AlbumsView.swift +++ b/Sources/ExyteMediaPicker/Screens/AlbumsView/AlbumsView.swift @@ -3,28 +3,25 @@ // import SwiftUI -import Combine struct AlbumsView: View { @EnvironmentObject private var selectionService: SelectionService - @EnvironmentObject private var permissionsService: PermissionsService + @Environment(\.mediaPickerTheme) private var theme @StateObject var viewModel: AlbumsViewModel @ObservedObject var mediaPickerViewModel: MediaPickerViewModel + @ObservedObject var permissionsService = PermissionsService.shared @Binding var showingCamera: Bool @Binding var currentFullscreenMedia: Media? let selectionParamsHolder: SelectionParamsHolder + let mediaPickerParamsHolder: MediaPickerParamsHolder let filterClosure: MediaPicker.FilterClosure? let massFilterClosure: MediaPicker.MassFilterClosure? @State private var showingLoadingCell = false - - private var columns: [GridItem] { - [GridItem(.adaptive(minimum: 100), spacing: 0, alignment: .top)] - } private var cellPadding: EdgeInsets { EdgeInsets(top: 2, leading: 2, bottom: 8, trailing: 2) @@ -33,24 +30,24 @@ struct AlbumsView: View { var body: some View { ScrollView { VStack { - if let action = permissionsService.photoLibraryAction { - PermissionsActionView(action: .library(action)) - } + PermissionActionView(type: .library(permissionsService.photoLibraryPermissionStatus)) + if viewModel.isLoading { ProgressView() + .padding() } else if viewModel.albums.isEmpty { Text("Empty data") .font(.title3) + .foregroundColor(theme.main.pickerText) } else { + let (columnWidth, columns) = calculateColumnWidth(spacing: 0) LazyVGrid(columns: columns, spacing: 0) { ForEach(viewModel.albums) { album in - AlbumCell( - viewModel: AlbumCellViewModel(album: album) - ) - .padding(cellPadding) - .onTapGesture { - mediaPickerViewModel.setPickerMode(.album(album.toAlbum())) - } + AlbumCell(viewModel: AlbumCellViewModel(album: album), size: columnWidth) + .padding(cellPadding) + .onTapGesture { + mediaPickerViewModel.setPickerMode(.album(album.toAlbum())) + } } } } diff --git a/Sources/ExyteMediaPicker/Screens/AlbumsView/AlbumsViewModel.swift b/Sources/ExyteMediaPicker/Screens/AlbumsView/AlbumsViewModel.swift new file mode 100644 index 0000000..415973d --- /dev/null +++ b/Sources/ExyteMediaPicker/Screens/AlbumsView/AlbumsViewModel.swift @@ -0,0 +1,31 @@ +// +// Created by Alex.M on 07.06.2022. +// + +import Foundation + +@MainActor +final class AlbumsViewModel: ObservableObject { + + var albums: [AlbumModel] { + albumsProvider.albums + } + + var isLoading: Bool { + albumsProvider.isLoading + } + + private let albumsProvider: DefaultAlbumsProvider + + init(albumsProvider: DefaultAlbumsProvider) { + self.albumsProvider = albumsProvider + } + + func onStart() { + albumsProvider.reload() + } + + func onStop() { + albumsProvider.cancelReload() + } +} diff --git a/Sources/ExyteMediaPicker/Screens/Camera/CameraSelectionContainer.swift b/Sources/ExyteMediaPicker/Screens/Camera/CameraSelectionContainer.swift new file mode 100644 index 0000000..5d9bc4e --- /dev/null +++ b/Sources/ExyteMediaPicker/Screens/Camera/CameraSelectionContainer.swift @@ -0,0 +1,106 @@ +// +// CameraSelectionContainer.swift +// +// +// Created by Alisa Mylnikova on 12.07.2022. +// + +import SwiftUI + +public struct CameraSelectionView: View { + + @EnvironmentObject private var cameraSelectionService: CameraSelectionService + @State private var index: Int? = 0 + + var selectionParamsHolder: SelectionParamsHolder + + public var body: some View { + GeometryReader { g in + let size = g.size + if #available(iOS 17.0, *) { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 0) { + ForEach(0..: View { + @EnvironmentObject private var cameraSelectionService: CameraSelectionService + + public typealias CameraViewClosure = ((LiveCameraView, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure) -> CameraViewContent) + + // params @ObservedObject var viewModel: MediaPickerViewModel let didTakePicture: () -> Void let didPressCancel: () -> Void + var cameraViewBuilder: CameraViewClosure @StateObject private var cameraViewModel = CameraViewModel() + + var body: some View { + cameraViewBuilder( + LiveCameraView( + session: cameraViewModel.captureSession, + videoGravity: .resizeAspectFill, + orientation: .portrait + ), + { // cancel + if cameraSelectionService.hasSelected { + viewModel.showingExitCameraConfirmation = true + } else { + didPressCancel() + } + }, + { viewModel.setPickerMode(.cameraSelection) }, // show preview of taken photos + { Task { await cameraViewModel.takePhoto() } }, // takePhoto + { Task { await cameraViewModel.startVideoCapture() } }, // start record video + { Task { await cameraViewModel.stopVideoCapture() } }, // stop record video + { Task { await cameraViewModel.toggleFlash() } }, // flash off/on + { Task { await cameraViewModel.flipCamera() } } // camera back/front + ) + .onChange(of: cameraViewModel.capturedPhoto) { _ , newValue in + viewModel.pickedMediaUrl = newValue + didTakePicture() + } + } +} + +struct StandardConrolsCameraView: View { + @EnvironmentObject private var cameraSelectionService: CameraSelectionService - @Environment(\.safeAreaInsets) private var safeAreaInsets @Environment(\.mediaPickerTheme) private var theme + @Environment(\.scenePhase) private var scenePhase - @State var capturingPhotos = true - @State var videoCaptureInProgress = false - + @ObservedObject var viewModel: MediaPickerViewModel + let didTakePicture: () -> Void + let didPressCancel: () -> Void let selectionParamsHolder: SelectionParamsHolder + @StateObject private var cameraViewModel = CameraViewModel() + + @State private var capturingPhotos = true + @State private var videoCaptureInProgress = false + var body: some View { VStack(spacing: 0) { HStack { @@ -31,13 +73,12 @@ struct CameraView: View { didPressCancel() } } - .foregroundColor(.white) - .padding(.top, safeAreaInsets.top) - .padding(.leading) - .padding(.bottom) + .foregroundColor(theme.main.cameraText) + .padding(12, 18) Spacer() } + .safeAreaPadding(.top, UIApplication.safeArea.top) LiveCameraView( session: cameraViewModel.captureSession, @@ -51,8 +92,16 @@ struct CameraView: View { } .gesture( MagnificationGesture() - .onChanged(cameraViewModel.zoomChanged(_:)) - .onEnded(cameraViewModel.zoomEnded(_:)) + .onChanged { value in + if cameraViewModel.zoomAllowed { + cameraViewModel.zoomChanged(value) + } + } + .onEnded { value in + if cameraViewModel.zoomAllowed { + cameraViewModel.zoomEnded(value) + } + } ) VStack(spacing: 10) { @@ -70,20 +119,22 @@ struct CameraView: View { Spacer() Text("\(cameraSelectionService.selected.count)") .font(.system(size: 15)) + .foregroundStyle(theme.main.cameraText) .padding(8) .overlay(Circle() - .stroke(Color.white, lineWidth: 2)) + .stroke(theme.main.cameraText, lineWidth: 2)) } - .foregroundColor(.white) + .foregroundColor(theme.main.cameraText) .padding(.horizontal, 12) } else if selectionParamsHolder.mediaType.allowsVideo { photoVideoToggle + .padding(.bottom, 8) } HStack(spacing: 40) { - Button { - cameraViewModel.toggleFlash() + AsyncButton { + await cameraViewModel.toggleFlash() } label: { Image(cameraViewModel.flashEnabled ? "FlashOn" : "FlashOff", bundle: .current) } @@ -96,22 +147,31 @@ struct CameraView: View { stopVideoCaptureButton } - Button { - cameraViewModel.flipCamera() + AsyncButton { + await cameraViewModel.flipCamera() } label: { Image("FlipCamera", bundle: .current) } } } .padding(.top, 24) - .padding(.bottom, safeAreaInsets.bottom + 50) + .padding(.bottom, 50) } .background(theme.main.cameraBackground) - .onEnteredBackground(perform: cameraViewModel.stopSession) - .onEnteredForeground(perform: cameraViewModel.startSession) - .onReceive(cameraViewModel.capturedPhotoPublisher) { - viewModel.pickedMediaUrl = $0 - didTakePicture() + .onChange(of: scenePhase) { + Task { + if scenePhase == .background { + await cameraViewModel.stopSession() + } else if scenePhase == .active { + await cameraViewModel.startSession() + } + } + } + .onChange(of: cameraViewModel.capturedPhoto) { _ , newValue in + if let photo = newValue { + viewModel.pickedMediaUrl = photo + didTakePicture() + } } } @@ -136,7 +196,9 @@ struct CameraView: View { .frame(width: 72, height: 72) Button { - cameraViewModel.takePhoto() + Task { + await cameraViewModel.takePhoto() + } } label: { Circle() .foregroundColor(.white) @@ -151,8 +213,8 @@ struct CameraView: View { .stroke(Color.white.opacity(0.4), lineWidth: 6) .frame(width: 72, height: 72) - Button { - cameraViewModel.startVideoCapture() + AsyncButton { + await cameraViewModel.startVideoCapture() videoCaptureInProgress = true } label: { Circle() @@ -168,8 +230,8 @@ struct CameraView: View { .stroke(Color.white.opacity(0.4), lineWidth: 6) .frame(width: 72, height: 72) - Button { - cameraViewModel.stopVideoCapture() + AsyncButton { + await cameraViewModel.stopVideoCapture() videoCaptureInProgress = false } label: { RoundedRectangle(cornerRadius: 10) diff --git a/Sources/ExyteMediaPicker/Views/Pages/Camera/CameraViewModel.swift b/Sources/ExyteMediaPicker/Screens/Camera/CameraViewModel.swift similarity index 56% rename from Sources/ExyteMediaPicker/Views/Pages/Camera/CameraViewModel.swift rename to Sources/ExyteMediaPicker/Screens/Camera/CameraViewModel.swift index f71923d..8e21fc9 100644 --- a/Sources/ExyteMediaPicker/Views/Pages/Camera/CameraViewModel.swift +++ b/Sources/ExyteMediaPicker/Screens/Camera/CameraViewModel.swift @@ -7,11 +7,17 @@ import Foundation import AVFoundation -import Combine import UIKit import SwiftUI +import Combine + +#if compiler(>=6.0) +extension AVCaptureSession: @retroactive @unchecked Sendable { } +#else +extension AVCaptureSession: @unchecked Sendable { } +#endif -final class CameraViewModel: NSObject, ObservableObject { +final actor CameraViewModel: NSObject, ObservableObject { struct CaptureDevice { let device: AVCaptureDevice @@ -20,17 +26,16 @@ final class CameraViewModel: NSObject, ObservableObject { let maxZoom: CGFloat } - @Published private(set) var flashEnabled = false - @Published private(set) var snapOverlay = false + @MainActor @Published private(set) var flashEnabled = false + @MainActor @Published private(set) var snapOverlay = false + @MainActor @Published private(set) var zoomAllowed = false + @MainActor @Published private(set) var capturedPhoto: URL? let captureSession = AVCaptureSession() - var capturedPhotoPublisher: AnyPublisher { capturedPhotoSubject.eraseToAnyPublisher() } private let photoOutput = AVCapturePhotoOutput() private let videoOutput = AVCaptureMovieFileOutput() private let motionManager = MotionManager() - private let sessionQueue = DispatchQueue(label: "LiveCameraQueue") - private let capturedPhotoSubject = PassthroughSubject() private var captureDevice: CaptureDevice? private var lastPhotoActualOrientation: UIDeviceOrientation? @@ -39,44 +44,49 @@ final class CameraViewModel: NSObject, ObservableObject { private let dualCameraMaxScale: CGFloat = 8 private let tripleCameraMaxScale: CGFloat = 12 private var lastScale: CGFloat = 1 - private var zoomAllowed: Bool { captureDevice?.position == .back } override init() { super.init() - sessionQueue.async { [weak self] in - self?.configureSession() - self?.captureSession.startRunning() + Task { + await configureSession() + captureSession.startRunning() } } - deinit { - captureSession.stopRunning() - } - func startSession() { - sessionQueue.async { [weak self] in - self?.captureSession.startRunning() - } + captureSession.startRunning() } func stopSession() { - sessionQueue.async { [weak self] in - self?.captureSession.stopRunning() + captureSession.stopRunning() + } + + func setCapturedPhoto(_ photo: URL?) { + DispatchQueue.main.async { + self.capturedPhoto = photo } } - func takePhoto() { + func takePhoto() async { let settings = AVCapturePhotoSettings() - settings.flashMode = flashEnabled ? .on : .off + settings.flashMode = await flashEnabled ? .on : .off photoOutput.capturePhoto(with: settings, delegate: self) lastPhotoActualOrientation = motionManager.orientation - withAnimation(.linear(duration: 0.1)) { snapOverlay = true } - withAnimation(.linear(duration: 0.1).delay(0.1)) { snapOverlay = false } + withAnimation(.linear(duration: 0.1)) { + DispatchQueue.main.async { + self.snapOverlay = true + } + } + withAnimation(.linear(duration: 0.1).delay(0.1)) { + DispatchQueue.main.async { + self.snapOverlay = false + } + } } - func startVideoCapture() { - setVideoTorchMode(flashEnabled ? .on : .off) + func startVideoCapture() async { + setVideoTorchMode(await flashEnabled ? .on : .off) let videoUrl = FileManager.getTempUrl() videoOutput.startRecording(to: videoUrl, recordingDelegate: self) @@ -96,34 +106,41 @@ final class CameraViewModel: NSObject, ObservableObject { } func flipCamera() { - sessionQueue.async { [weak self] in - guard let session = self?.captureSession, let input = session.inputs.first else { - return - } - - let newPosition: AVCaptureDevice.Position = self?.captureDevice?.position == .back ? .front : .back - - session.beginConfiguration() - session.removeInput(input) - self?.addInput(to: session, for: newPosition) - session.commitConfiguration() + let session = captureSession + guard let input = session.inputs + .compactMap({ $0 as? AVCaptureDeviceInput }) + .first(where: { $0.device.hasMediaType(.video) }) else { + return } + let newPosition: AVCaptureDevice.Position = captureDevice?.position == .back ? .front : .back + + session.beginConfiguration() + session.removeInput(input) + addInput(to: session, for: newPosition) + session.commitConfiguration() } func toggleFlash() { - flashEnabled.toggle() + DispatchQueue.main.async { + self.flashEnabled.toggle() + } } - func zoomChanged(_ scale: CGFloat) { - if !zoomAllowed { return } - zoomCamera(resolveScale(scale)) + nonisolated func zoomChanged(_ scale: CGFloat) { + Task { + await zoomCamera(await resolveScale(scale)) + } } - func zoomEnded(_ scale: CGFloat) { - if !zoomAllowed { return } + nonisolated func zoomEnded(_ scale: CGFloat) { + Task { + await setLastScale(await resolveScale(scale)) + await zoomCamera(lastScale) + } + } - lastScale = resolveScale(scale) - zoomCamera(lastScale) + private func setLastScale(_ scale: CGFloat) { + self.lastScale = scale } private func resolveScale(_ gestureScale: CGFloat) -> CGFloat { @@ -150,10 +167,22 @@ final class CameraViewModel: NSObject, ObservableObject { private func addInput(to session: AVCaptureSession, for position: AVCaptureDevice.Position = .back) { guard let captureDevice = selectCaptureDevice(for: position) else { return } + let zoomAllowed = captureDevice.position == .back + Task { @MainActor in + self.zoomAllowed = zoomAllowed + } guard let captureDeviceInput = try? AVCaptureDeviceInput(device: captureDevice) else { return } guard session.canAddInput(captureDeviceInput) else { return } session.addInput(captureDeviceInput) + let hasAudioInput = session.inputs.contains { ($0 as? AVCaptureDeviceInput)?.device.hasMediaType(.audio) == true } + if !hasAudioInput { + guard let captureAudioDevice = selectAudioCaptureDevice() else { return } + guard let captureAudioDeviceInput = try? AVCaptureDeviceInput(device: captureAudioDevice) else { return } + guard session.canAddInput(captureAudioDeviceInput) else { return } + session.addInput(captureAudioDeviceInput) + } + let defaultZoom = CGFloat(truncating: captureDevice.virtualDeviceSwitchOverVideoZoomFactors.first ?? minScale as NSNumber) let maxZoom: CGFloat @@ -186,12 +215,15 @@ final class CameraViewModel: NSObject, ObservableObject { guard session.canAddOutput(videoOutput) else { return } session.addOutput(videoOutput) - updateOutputOrientation() + updateOutputOrientation(photoOutput) + updateOutputOrientation(videoOutput) } - private func updateOutputOrientation() { - guard let connection = photoOutput.connection(with: .video), connection.isVideoOrientationSupported else { return } - connection.videoOrientation = .portrait + private func updateOutputOrientation(_ output: AVCaptureOutput) { + guard let connection = output.connection(with: .video) else { return } + if connection.isVideoRotationAngleSupported(0) { + connection.videoRotationAngle = 0 + } } private func selectCaptureDevice(for position: AVCaptureDevice.Position) -> AVCaptureDevice? { @@ -217,35 +249,47 @@ final class CameraViewModel: NSObject, ObservableObject { } } + private func selectAudioCaptureDevice() -> AVCaptureDevice? { + let session = AVCaptureDevice.DiscoverySession( + deviceTypes: [.microphone], + mediaType: .audio, + position: .unspecified) + + return session.devices.first + } } extension CameraViewModel: AVCapturePhotoCaptureDelegate { - func photoOutput( + nonisolated func photoOutput( _ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error? ) { guard let cgImage = photo.cgImageRepresentation() else { return } - let photoOrientation: UIImage.Orientation - if let orientation = lastPhotoActualOrientation { - photoOrientation = UIImage.Orientation(orientation) - } else { - photoOrientation = UIImage.Orientation.default - } + Task { + let photoOrientation: UIImage.Orientation + if let orientation = await lastPhotoActualOrientation { + photoOrientation = UIImage.Orientation(orientation) + } else { + photoOrientation = UIImage.Orientation.default + } - guard let data = UIImage( - cgImage: cgImage, - scale: 1, - orientation: photoOrientation - ).jpegData(compressionQuality: 0.8) else { return } + guard let data = UIImage( + cgImage: cgImage, + scale: 1, + orientation: photoOrientation + ).jpegData(compressionQuality: 0.8) else { return } - capturedPhotoSubject.send(FileManager.storeToTempDir(data: data)) + await setCapturedPhoto(FileManager.storeToTempDir(data: data)) + } } } extension CameraViewModel: AVCaptureFileOutputRecordingDelegate { - func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { - capturedPhotoSubject.send(outputFileURL) + nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { + Task { + await setCapturedPhoto(outputFileURL) + } } } diff --git a/Sources/ExyteMediaPicker/Views/Widgets/LiveCameraCell.swift b/Sources/ExyteMediaPicker/Screens/Camera/LiveCameraCell.swift similarity index 54% rename from Sources/ExyteMediaPicker/Views/Widgets/LiveCameraCell.swift rename to Sources/ExyteMediaPicker/Screens/Camera/LiveCameraCell.swift index f64b4db..7b1c10a 100644 --- a/Sources/ExyteMediaPicker/Views/Widgets/LiveCameraCell.swift +++ b/Sources/ExyteMediaPicker/Screens/Camera/LiveCameraCell.swift @@ -5,10 +5,12 @@ import SwiftUI struct LiveCameraCell: View { + + @Environment(\.scenePhase) private var scenePhase let action: () -> Void - @StateObject private var liveCameraViewModel = LiveCameraViewModel() + @StateObject private var cameraViewModel = CameraViewModel() @State private var orientation = UIDevice.current.orientation var body: some View { @@ -16,7 +18,7 @@ struct LiveCameraCell: View { action() } label: { LiveCameraView( - session: liveCameraViewModel.captureSession, + session: cameraViewModel.captureSession, videoGravity: .resizeAspectFill, orientation: orientation ) @@ -25,8 +27,15 @@ struct LiveCameraCell: View { .foregroundColor(.white) ) } - .onEnteredBackground(perform: liveCameraViewModel.stopSession) - .onEnteredForeground(perform: liveCameraViewModel.startSession) + .onChange(of: scenePhase) { + Task { + if scenePhase == .background { + await cameraViewModel.stopSession() + } else if scenePhase == .active { + await cameraViewModel.startSession() + } + } + } .onRotate { orientation = $0 } } } diff --git a/Sources/ExyteMediaPicker/Views/Pages/Camera/LiveCameraView.swift b/Sources/ExyteMediaPicker/Screens/Camera/LiveCameraView.swift similarity index 55% rename from Sources/ExyteMediaPicker/Views/Pages/Camera/LiveCameraView.swift rename to Sources/ExyteMediaPicker/Screens/Camera/LiveCameraView.swift index c006444..db4daae 100644 --- a/Sources/ExyteMediaPicker/Views/Pages/Camera/LiveCameraView.swift +++ b/Sources/ExyteMediaPicker/Screens/Camera/LiveCameraView.swift @@ -8,13 +8,14 @@ import SwiftUI import AVFoundation -struct LiveCameraView: UIViewRepresentable { +@MainActor +public struct LiveCameraView: UIViewRepresentable { let session: AVCaptureSession - var videoGravity: AVLayerVideoGravity = .resizeAspect - var orientation: UIDeviceOrientation = UIDevice.current.orientation + let videoGravity: AVLayerVideoGravity + let orientation: UIDeviceOrientation - func makeUIView(context: Context) -> LiveVideoCaptureView { + public func makeUIView(context: Context) -> LiveVideoCaptureView { LiveVideoCaptureView( session: session, videoGravity: videoGravity, @@ -22,13 +23,10 @@ struct LiveCameraView: UIViewRepresentable { ) } - func updateUIView(_ uiView: LiveVideoCaptureView, context: Context) { - uiView.updateOrientation(orientation) - } - + public func updateUIView(_ uiView: LiveVideoCaptureView, context: Context) { } } -final class LiveVideoCaptureView: UIView { +public final class LiveVideoCaptureView: UIView { var session: AVCaptureSession? { get { @@ -39,7 +37,7 @@ final class LiveVideoCaptureView: UIView { } } - override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } + public override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } private var videoLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } @@ -56,12 +54,5 @@ final class LiveVideoCaptureView: UIView { super.init(frame: frame) self.session = session videoLayer.videoGravity = videoGravity - updateOrientation(orientation) } - - func updateOrientation(_ orientation: UIDeviceOrientation) { - guard let connection = videoLayer.connection, connection.isVideoOrientationSupported else { return } - connection.videoOrientation = AVCaptureVideoOrientation(orientation) - } - } diff --git a/Sources/ExyteMediaPicker/Screens/Fullscreen/FullscreenCell.swift b/Sources/ExyteMediaPicker/Screens/Fullscreen/FullscreenCell.swift new file mode 100644 index 0000000..360cac0 --- /dev/null +++ b/Sources/ExyteMediaPicker/Screens/Fullscreen/FullscreenCell.swift @@ -0,0 +1,73 @@ +// +// Created by Alex.M on 09.06.2022. +// + +import Foundation +import SwiftUI +import AVKit + +struct FullscreenCell: View { + + @Environment(\.mediaPickerTheme) private var theme + + @StateObject var viewModel: FullscreenCellViewModel + @ObservedObject var keyboardHeightHelper = KeyboardHeightHelper.shared + + var size: CGSize + + var body: some View { + Group { + if let image = viewModel.image { + ZoomableScrollView { + imageView(image: image, useFill: false) + } + } else if let player = viewModel.player { + ZoomableScrollView { + videoView(player: player, useFill: false) + } + } else { + ProgressView() + .tint(.white) + } + } + .allowsHitTesting(!keyboardHeightHelper.keyboardDisplayed) + .task { + await viewModel.onStart() + } + .onDisappear { + viewModel.onStop() + } + } + + @ViewBuilder + func imageView(image: UIImage, useFill: Bool) -> some View { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: useFill ? .fill : .fit) + } + + func videoView(player: AVPlayer, useFill: Bool) -> some View { + PlayerView(player: player, bgColor: theme.main.fullscreenPhotoBackground, useFill: useFill) + .disabled(true) + .overlay { + ZStack { + Color.clear + if !viewModel.isPlaying { + Circle().styled(.black.opacity(0.2)) + .frame(width: 70, height: 70) + Image(systemName: "play.fill") + .resizable() + .foregroundColor(.white.opacity(0.8)) + .frame(width: 30, height: 30) + .padding(.leading, 4) + } + } + .contentShape(Rectangle()) + .simultaneousGesture( + TapGesture().onEnded { + viewModel.togglePlay() + } + ) + } + } +} diff --git a/Sources/ExyteMediaPicker/Views/Pages/Fullscreen/FullscreenCellViewModel.swift b/Sources/ExyteMediaPicker/Screens/Fullscreen/FullscreenCellViewModel.swift similarity index 65% rename from Sources/ExyteMediaPicker/Views/Pages/Fullscreen/FullscreenCellViewModel.swift rename to Sources/ExyteMediaPicker/Screens/Fullscreen/FullscreenCellViewModel.swift index 758b795..49fcb15 100644 --- a/Sources/ExyteMediaPicker/Views/Pages/Fullscreen/FullscreenCellViewModel.swift +++ b/Sources/ExyteMediaPicker/Screens/Fullscreen/FullscreenCellViewModel.swift @@ -3,10 +3,15 @@ // import Foundation -import Combine -import AVKit +@preconcurrency import AVKit import UIKit.UIImage +#if compiler(>=6.0) +extension AVAssetTrack: @retroactive @unchecked Sendable { } +#else +extension AVAssetTrack: @unchecked Sendable { } +#endif + @MainActor final class FullscreenCellViewModel: ObservableObject { @@ -15,6 +20,7 @@ final class FullscreenCellViewModel: ObservableObject { @Published var image: UIImage? = nil @Published var player: AVPlayer? = nil @Published var isPlaying = false + @Published var videoSize: CGSize = .zero private var currentTask: Task? @@ -31,13 +37,15 @@ final class FullscreenCellViewModel: ObservableObject { case .image: let data = try? await mediaModel.getData() // url is slow to load in UI, this way photos don't flicker when swiping guard let data = data else { return } + let result = UIImage(data: data) DispatchQueue.main.async { - self.image = UIImage(data: data) + self.image = result } case .video: let url = await mediaModel.getURL() guard let url = url else { return } setupPlayer(url) + videoSize = await getVideoSize(url) case .none: break } @@ -71,4 +79,17 @@ final class FullscreenCellViewModel: ObservableObject { } isPlaying = !isPlaying } + + func getVideoSize(_ url: URL) async -> CGSize { + let videoAsset = AVURLAsset(url : url) + + let videoAssetTrack = try? await videoAsset.loadTracks(withMediaType: .video).first + let naturalSize = (try? await videoAssetTrack?.load(.naturalSize)) ?? .zero + let transform = try? await videoAssetTrack?.load(.preferredTransform) + if (transform?.tx == naturalSize.width && transform?.ty == naturalSize.height) || (transform?.tx == 0 && transform?.ty == 0) { + return naturalSize + } else { + return CGSize(width: naturalSize.height, height: naturalSize.width) + } + } } diff --git a/Sources/ExyteMediaPicker/Screens/Fullscreen/FullscreenContainer.swift b/Sources/ExyteMediaPicker/Screens/Fullscreen/FullscreenContainer.swift new file mode 100644 index 0000000..69e7057 --- /dev/null +++ b/Sources/ExyteMediaPicker/Screens/Fullscreen/FullscreenContainer.swift @@ -0,0 +1,134 @@ +// +// Created by Alex.M on 09.06.2022. +// + +import Foundation +import SwiftUI +import AnchoredPopup + +struct FullscreenContainer: View { + + @EnvironmentObject private var selectionService: SelectionService + @Environment(\.mediaPickerTheme) private var theme + + @ObservedObject var keyboardHeightHelper = KeyboardHeightHelper.shared + + @Binding var currentFullscreenMedia: Media? + @Binding var selection: AssetMediaModel.ID? + let animationID: String + let assetMediaModels: [AssetMediaModel] + var selectionParamsHolder: SelectionParamsHolder + var dismiss: ()->() + + private var selectedMediaModel: AssetMediaModel? { + assetMediaModels.first { $0.id == selection } + } + + private var selectionServiceIndex: Int? { + guard let selectedMediaModel = selectedMediaModel else { + return nil + } + return selectionService.index(of: selectedMediaModel) + } + + var body: some View { + VStack { + controlsOverlay + GeometryReader { g in + contentView(g.size) + } + } + .safeAreaPadding(.top, UIApplication.safeArea.top) + .background { + theme.main.fullscreenPhotoBackground + .ignoresSafeArea() + } + .onAppear { + if let selectedMediaModel { + currentFullscreenMedia = Media(source: selectedMediaModel) + } + } + .onDisappear { + currentFullscreenMedia = nil + } + .onChange(of: selection) { + if let selectedMediaModel { + currentFullscreenMedia = Media(source: selectedMediaModel) + } + } + .onTapGesture { + if keyboardHeightHelper.keyboardDisplayed { + dismissKeyboard() + } else { + if let selectedMediaModel = selectedMediaModel, selectedMediaModel.mediaType == .image { + selectionService.onSelect(assetMediaModel: selectedMediaModel) + } + } + } + } + + @ViewBuilder + func contentView(_ size: CGSize) -> some View { + if #available(iOS 17.0, *) { + ScrollViewReader { scrollReader in + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 0) { + ForEach(assetMediaModels, id: \.id) { assetMediaModel in + FullscreenCell(viewModel: FullscreenCellViewModel(mediaModel: assetMediaModel), size: size) + .frame(width: size.width, height: size.height) + .id(assetMediaModel.id) + } + } + .scrollTargetLayout() + } + .scrollTargetBehavior(.viewAligned) + .scrollPosition(id: $selection) + .onAppear { + scrollReader.scrollTo(selection) + } + } + } else { + TabView(selection: $selection) { + ForEach(assetMediaModels, id: \.id) { assetMediaModel in + FullscreenCell(viewModel: FullscreenCellViewModel(mediaModel: assetMediaModel), size: size) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .tag(assetMediaModel.id) + } + } + } + } + + var controlsOverlay: some View { + HStack { + Image(systemName: "xmark") + .resizable() + .frame(width: 20, height: 20) + .padding(20, 16) + .contentShape(Rectangle()) + .onTapGesture { + selection = nil + AnchoredPopup.launchShrinkingAnimation(id: animationID) + } + + Spacer() + + if let selectedMediaModel = selectedMediaModel { + if selectionParamsHolder.selectionLimit == 1 { + Button("Select") { + AnchoredPopup.launchShrinkingAnimation(id: animationID) + selectionService.onSelect(assetMediaModel: selectedMediaModel) + dismiss() + } + .padding(.horizontal, 20) + } else { + SelectionIndicatorView(index: selectionServiceIndex, isFullscreen: true, canSelect: selectionService.canSelect(assetMediaModel: selectedMediaModel), selectionParamsHolder: selectionParamsHolder) + .padding(.horizontal, 20) + .onTapGesture { + selectionService.onSelect(assetMediaModel: selectedMediaModel) // for video selection, since tap on video is toggle play + } + } + } + } + .foregroundStyle(theme.selection.fullscreenSelectedBackground) + } +} diff --git a/Sources/ExyteMediaPicker/Views/Pages/MediaPicker/AlbumSelectionView.swift b/Sources/ExyteMediaPicker/Screens/MediaPicker/AlbumSelectionView.swift similarity index 69% rename from Sources/ExyteMediaPicker/Views/Pages/MediaPicker/AlbumSelectionView.swift rename to Sources/ExyteMediaPicker/Screens/MediaPicker/AlbumSelectionView.swift index a11f217..8034743 100644 --- a/Sources/ExyteMediaPicker/Views/Pages/MediaPicker/AlbumSelectionView.swift +++ b/Sources/ExyteMediaPicker/Screens/MediaPicker/AlbumSelectionView.swift @@ -13,25 +13,23 @@ public struct AlbumSelectionView: View { @Binding var showingCamera: Bool @Binding var currentFullscreenMedia: Media? - let showingLiveCameraCell: Bool + let selectionParamsHolder: SelectionParamsHolder + let mediaPickerParamsHolder: MediaPickerParamsHolder let filterClosure: MediaPicker.FilterClosure? let massFilterClosure: MediaPicker.MassFilterClosure? - - @State private var showingLoadingCell = false + var dismiss: ()->() public var body: some View { switch viewModel.internalPickerMode { case .photos: AlbumView( - viewModel: AlbumViewModel( - mediasProvider: AllPhotosProvider(selectionParamsHolder: selectionParamsHolder, filterClosure: filterClosure, massFilterClosure: massFilterClosure, showingLoadingCell: $showingLoadingCell) - ), + viewModel: AllMediasProvider(selectionParamsHolder: selectionParamsHolder, filterClosure: filterClosure, massFilterClosure: massFilterClosure), showingCamera: $showingCamera, currentFullscreenMedia: $currentFullscreenMedia, - shouldShowCamera: showingLiveCameraCell, - shouldShowLoadingCell: showingLoadingCell, - selectionParamsHolder: selectionParamsHolder + selectionParamsHolder: selectionParamsHolder, + mediaPickerParamsHolder: mediaPickerParamsHolder, + dismiss: dismiss ) case .albums: AlbumsView( @@ -42,6 +40,7 @@ public struct AlbumSelectionView: View { showingCamera: $showingCamera, currentFullscreenMedia: $currentFullscreenMedia, selectionParamsHolder: selectionParamsHolder, + mediaPickerParamsHolder: mediaPickerParamsHolder, filterClosure: filterClosure, massFilterClosure: massFilterClosure ) @@ -51,14 +50,12 @@ public struct AlbumSelectionView: View { case .album(let album): if let albumModel = viewModel.getAlbumModel(album) { AlbumView( - viewModel: AlbumViewModel( - mediasProvider: AlbumMediasProvider(album: albumModel, selectionParamsHolder: selectionParamsHolder, filterClosure: filterClosure, massFilterClosure: massFilterClosure, showingLoadingCell: $showingLoadingCell) - ), + viewModel: AlbumMediasProvider(album: albumModel, selectionParamsHolder: SelectionParamsHolder(), filterClosure: filterClosure, massFilterClosure: massFilterClosure), showingCamera: $showingCamera, currentFullscreenMedia: $currentFullscreenMedia, - shouldShowCamera: false, - shouldShowLoadingCell: showingLoadingCell, - selectionParamsHolder: selectionParamsHolder + selectionParamsHolder: selectionParamsHolder, + mediaPickerParamsHolder: MediaPickerParamsHolder(liveCameraCell: .none), + dismiss: dismiss ) .id(album.id) } diff --git a/Sources/ExyteMediaPicker/Screens/MediaPicker/GenenricsTrick.swift b/Sources/ExyteMediaPicker/Screens/MediaPicker/GenenricsTrick.swift new file mode 100644 index 0000000..9b93c18 --- /dev/null +++ b/Sources/ExyteMediaPicker/Screens/MediaPicker/GenenricsTrick.swift @@ -0,0 +1,110 @@ +// +// SwiftUIView.swift +// +// +// Created by Alisa Mylnikova on 18.10.2023. +// + +import SwiftUI + +// MARK: - Partial genereic specification imitation + +public extension MediaPicker where AlbumSelectionContent == EmptyView, CameraSelectionContent == EmptyView, CameraViewContent == EmptyView { + + init(isPresented: Binding, + onChange: @escaping MediaPickerCompletionClosure) { + + self.init(isPresented: isPresented, + onChange: onChange, + albumSelectionBuilder: nil, + cameraSelectionBuilder: nil, + cameraViewBuilder: nil) + } +} + +public extension MediaPicker where CameraSelectionContent == EmptyView, CameraViewContent == EmptyView { + + init(isPresented: Binding, + onChange: @escaping MediaPickerCompletionClosure, + albumSelectionBuilder: @escaping AlbumSelectionClosure) { + + self.init(isPresented: isPresented, + onChange: onChange, + albumSelectionBuilder: albumSelectionBuilder, + cameraSelectionBuilder: nil, + cameraViewBuilder: nil) + } +} + +public extension MediaPicker where AlbumSelectionContent == EmptyView, CameraViewContent == EmptyView { + + init(isPresented: Binding, + onChange: @escaping MediaPickerCompletionClosure, + cameraSelectionBuilder: @escaping CameraSelectionClosure) { + + self.init(isPresented: isPresented, + onChange: onChange, + albumSelectionBuilder: nil, + cameraSelectionBuilder: cameraSelectionBuilder, + cameraViewBuilder: nil) + } +} + +public extension MediaPicker where AlbumSelectionContent == EmptyView, CameraSelectionContent == EmptyView { + + init(isPresented: Binding, + onChange: @escaping MediaPickerCompletionClosure, + cameraViewBuilder: @escaping CameraViewClosure) { + + self.init(isPresented: isPresented, + onChange: onChange, + albumSelectionBuilder: nil, + cameraSelectionBuilder: nil, + cameraViewBuilder: cameraViewBuilder) + } +} + +public extension MediaPicker where CameraViewContent == EmptyView { + + init(isPresented: Binding, + onChange: @escaping MediaPickerCompletionClosure, + albumSelectionBuilder: @escaping AlbumSelectionClosure, + cameraSelectionBuilder: @escaping CameraSelectionClosure) { + + self.init(isPresented: isPresented, + onChange: onChange, + albumSelectionBuilder: albumSelectionBuilder, + cameraSelectionBuilder: cameraSelectionBuilder, + cameraViewBuilder: nil) + } +} + +public extension MediaPicker where CameraViewContent == EmptyView { + + init(isPresented: Binding, + onChange: @escaping MediaPickerCompletionClosure, + albumSelectionBuilder: @escaping AlbumSelectionClosure, + cameraViewBuilder: @escaping CameraViewClosure) { + + self.init(isPresented: isPresented, + onChange: onChange, + albumSelectionBuilder: albumSelectionBuilder, + cameraSelectionBuilder: nil, + cameraViewBuilder: cameraViewBuilder) + } +} + +public extension MediaPicker where AlbumSelectionContent == EmptyView { + + init(isPresented: Binding, + onChange: @escaping MediaPickerCompletionClosure, + cameraSelectionBuilder: @escaping CameraSelectionClosure, + cameraViewBuilder: @escaping CameraViewClosure) { + + self.init(isPresented: isPresented, + onChange: onChange, + albumSelectionBuilder: nil, + cameraSelectionBuilder: cameraSelectionBuilder, + cameraViewBuilder: cameraViewBuilder) + } +} diff --git a/Sources/ExyteMediaPicker/Views/Pages/MediaPicker/MediaPicker.swift b/Sources/ExyteMediaPicker/Screens/MediaPicker/MediaPicker.swift similarity index 54% rename from Sources/ExyteMediaPicker/Views/Pages/MediaPicker/MediaPicker.swift rename to Sources/ExyteMediaPicker/Screens/MediaPicker/MediaPicker.swift index c4d9c02..ebbad49 100644 --- a/Sources/ExyteMediaPicker/Views/Pages/MediaPicker/MediaPicker.swift +++ b/Sources/ExyteMediaPicker/Screens/MediaPicker/MediaPicker.swift @@ -3,23 +3,34 @@ // import SwiftUI -import Combine -public struct MediaPicker: View { +public struct MediaPicker: View { /// To provide custom buttons layout for photos grid view use actions and views provided by this closure: /// - standard header with photos/albums switcher /// - selection view you can embed in your view - public typealias AlbumSelectionClosure = ((ModeSwitcher, AlbumSelectionView) -> AlbumSelectionContent) + /// - is in fullscreen photo details mode + public typealias AlbumSelectionClosure = ((ModeSwitcher, AlbumSelectionView, Bool) -> AlbumSelectionContent) - /// To provide custom buttons layout for camera selection view use actions and views by this closure: + /// To provide custom buttons layout for camera selection view use actions and views provided by this closure: /// - add more photos closure /// - cancel closure /// - selection view you can embed in your view public typealias CameraSelectionClosure = ((@escaping SimpleClosure, @escaping SimpleClosure, CameraSelectionView) -> CameraSelectionContent) - public typealias FilterClosure = (Media) async -> Media? - public typealias MassFilterClosure = ([Media]) async -> [Media] + /// To provide custom buttons layout for camera view use actions and views provided by this closure: + /// - live camera capture view + /// - cancel closure + /// - show preview of taken photos + /// - take photo closure + /// - start record video closure + /// - stop record video closure + /// - flash off/on closure + /// - camera back/front closure + public typealias CameraViewClosure = ((LiveCameraView, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure, @escaping SimpleClosure) -> CameraViewContent) + + public typealias FilterClosure = @Sendable (Media) async -> Media? + public typealias MassFilterClosure = @Sendable ([Media]) async -> [Media] // MARK: - Parameters @@ -30,19 +41,20 @@ public struct MediaPicker? - private var showingLiveCameraCell: Bool = false private var didPressCancelCamera: (() -> Void)? private var orientationHandler: MediaPickerOrientationHandler = {_ in} private var filterClosure: FilterClosure? private var massFilterClosure: MassFilterClosure? private var selectionParamsHolder = SelectionParamsHolder() + private var mediaPickerParamsHolder = MediaPickerParamsHolder() // MARK: - Inner values @@ -51,75 +63,58 @@ public struct MediaPicker, onChange: @escaping MediaPickerCompletionClosure, - albumSelectionBuilder: @escaping AlbumSelectionClosure, - cameraSelectionBuilder: @escaping CameraSelectionClosure) { + albumSelectionBuilder: AlbumSelectionClosure? = nil, + cameraSelectionBuilder: CameraSelectionClosure? = nil, + cameraViewBuilder: CameraViewClosure? = nil) { self._isPresented = isPresented self._albums = .constant([]) - self._currentFullscreenMedia = .constant(nil) + self._currentFullscreenMediaBinding = .constant(nil) self.onChange = onChange self.albumSelectionBuilder = albumSelectionBuilder self.cameraSelectionBuilder = cameraSelectionBuilder + self.cameraViewBuilder = cameraViewBuilder } public var body: some View { - NavigationView { - Group { - switch viewModel.internalPickerMode { + Group { + switch internalPickerMode { // please don't use viewModel.internalPickerMode here - it slows down camera dismissal case .photos, .albums, .album(_): albumSelectionContainer case .camera: - ZStack { - Color.black - .ignoresSafeArea(.all) - .onAppear { - DispatchQueue.main.async { - readyToShowCamera = true - } - } - .onDisappear { - readyToShowCamera = false - } - if readyToShowCamera { - cameraSheet() { - // did take picture - if !cameraSelectionService.hasSelected { - viewModel.setPickerMode(.cameraSelection) - } - guard let url = viewModel.pickedMediaUrl else { return } - cameraSelectionService.onSelect(media: URLMediaModel(url: url)) - viewModel.pickedMediaUrl = nil - } didPressCancel: { - if let didPressCancel = didPressCancelCamera { - didPressCancel() - } else { - viewModel.setPickerMode(.photos) - } - } - .confirmationDialog("", isPresented: $viewModel.showingExitCameraConfirmation, titleVisibility: .hidden) { - deleteAllButton - } - } - } + cameraContainer case .cameraSelection: cameraSelectionContainer } - } } - .background(theme.main.albumSelectionBackground.ignoresSafeArea()) + .background(theme.main.pickerBackground.ignoresSafeArea()) .environmentObject(selectionService) .environmentObject(cameraSelectionService) - .environmentObject(permissionService) .onAppear { + PermissionsService.shared.updatePhotoLibraryAuthorizationStatus() +#if !targetEnvironment(simulator) + if mediaPickerParamsHolder.liveCameraCell != .none { + PermissionsService.shared.requestCameraPermission() + } else { + PermissionsService.shared.updateCameraAuthorizationStatus() + } +#endif + selectionService.onChange = onChange selectionService.mediaSelectionLimit = selectionParamsHolder.selectionLimit @@ -131,17 +126,20 @@ public struct MediaPicker Binding { @@ -243,12 +294,25 @@ public struct MediaPicker(), didPressCancel: @escaping ()->()) -> some View { #if targetEnvironment(simulator) - CameraStubView(isPresented: cameraBinding()) + CameraStubView { + didPressCancel() + } #elseif os(iOS) - CameraView(viewModel: viewModel, didTakePicture: didTakePicture, didPressCancel: didPressCancel, selectionParamsHolder: selectionParamsHolder) - .ignoresSafeArea() + Group { + if let cameraViewBuilder = cameraViewBuilder { + CustomCameraView(viewModel: viewModel, didTakePicture: didTakePicture, didPressCancel: didPressCancel, cameraViewBuilder: cameraViewBuilder) + .ignoresSafeArea() + } else { + StandardConrolsCameraView(viewModel: viewModel, didTakePicture: didTakePicture, didPressCancel: didPressCancel, selectionParamsHolder: selectionParamsHolder) + .ignoresSafeArea() + } + } + .onAppear { + PermissionsService.shared.requestCameraPermission() + } #endif } } @@ -257,10 +321,15 @@ public struct MediaPicker MediaPicker { - var mediaPicker = self - mediaPicker.showingLiveCameraCell = true - return mediaPicker + func liveCameraCell(_ style: LiveCameraCellStyle = .small) -> MediaPicker { + mediaPickerParamsHolder.liveCameraCell = style + return self + } + + @available(*, deprecated, message: "use liveCameraCell instead") + func showLiveCameraCell(_ show: Bool = true) -> MediaPicker { + mediaPickerParamsHolder.liveCameraCell = show ? .small : .none + return self } func mediaSelectionType(_ type: MediaSelectionType) -> MediaPicker { @@ -278,6 +347,29 @@ public extension MediaPicker { return self } + func showFullscreenPreview(_ show: Bool) -> MediaPicker { + selectionParamsHolder.showFullscreenPreview = show + return self + } + + func setMediaPickerParameters(_ params: MediaPickerParamsHolder?) -> MediaPicker { + guard let params = params else { + return self + } + var mediaPicker = self + mediaPicker.mediaPickerParamsHolder = params + return mediaPicker + } + + func setSelectionParameters(_ params: SelectionParamsHolder?) -> MediaPicker { + guard let params = params else { + return self + } + var mediaPicker = self + mediaPicker.selectionParamsHolder = params + return mediaPicker + } + func applyFilter(_ filterClosure: @escaping FilterClosure) -> MediaPicker { var mediaPicker = self mediaPicker.filterClosure = filterClosure @@ -304,7 +396,7 @@ public extension MediaPicker { func currentFullscreenMedia(_ currentFullscreenMedia: Binding) -> MediaPicker { var mediaPicker = self - mediaPicker._currentFullscreenMedia = currentFullscreenMedia + mediaPicker._currentFullscreenMediaBinding = currentFullscreenMedia return mediaPicker } @@ -320,48 +412,3 @@ public extension MediaPicker { return mediaPicker } } - -// MARK: - Partial genereic specification imitation - -public extension MediaPicker where AlbumSelectionContent == EmptyView, CameraSelectionContent == EmptyView { - - init(isPresented: Binding, - onChange: @escaping MediaPickerCompletionClosure) { - - self._isPresented = isPresented - self._albums = .constant([]) - self._currentFullscreenMedia = .constant(nil) - - self.onChange = onChange - } -} - -public extension MediaPicker where AlbumSelectionContent == EmptyView { - - init(isPresented: Binding, - onChange: @escaping MediaPickerCompletionClosure, - cameraSelectionBuilder: @escaping CameraSelectionClosure) { - - self._isPresented = isPresented - self._albums = .constant([]) - self._currentFullscreenMedia = .constant(nil) - - self.onChange = onChange - self.cameraSelectionBuilder = cameraSelectionBuilder - } -} - -public extension MediaPicker where CameraSelectionContent == EmptyView { - - init(isPresented: Binding, - onChange: @escaping MediaPickerCompletionClosure, - albumSelectionBuilder: @escaping AlbumSelectionClosure) { - - self._isPresented = isPresented - self._albums = .constant([]) - self._currentFullscreenMedia = .constant(nil) - - self.onChange = onChange - self.albumSelectionBuilder = albumSelectionBuilder - } -} diff --git a/Sources/ExyteMediaPicker/Views/Pages/MediaPicker/MediaPickerMode.swift b/Sources/ExyteMediaPicker/Screens/MediaPicker/MediaPickerMode.swift similarity index 100% rename from Sources/ExyteMediaPicker/Views/Pages/MediaPicker/MediaPickerMode.swift rename to Sources/ExyteMediaPicker/Screens/MediaPicker/MediaPickerMode.swift diff --git a/Sources/ExyteMediaPicker/Screens/MediaPicker/MediaPickerParamsHolder.swift b/Sources/ExyteMediaPicker/Screens/MediaPicker/MediaPickerParamsHolder.swift new file mode 100644 index 0000000..dda203d --- /dev/null +++ b/Sources/ExyteMediaPicker/Screens/MediaPicker/MediaPickerParamsHolder.swift @@ -0,0 +1,8 @@ +public class MediaPickerParamsHolder { + + var liveCameraCell: LiveCameraCellStyle + + public init(liveCameraCell: LiveCameraCellStyle = LiveCameraCellStyle.small) { + self.liveCameraCell = liveCameraCell + } +} diff --git a/Sources/ExyteMediaPicker/Views/Pages/MediaPicker/MediaPickerViewModel.swift b/Sources/ExyteMediaPicker/Screens/MediaPicker/MediaPickerViewModel.swift similarity index 70% rename from Sources/ExyteMediaPicker/Views/Pages/MediaPicker/MediaPickerViewModel.swift rename to Sources/ExyteMediaPicker/Screens/MediaPicker/MediaPickerViewModel.swift index ddc0226..fe1be83 100644 --- a/Sources/ExyteMediaPicker/Views/Pages/MediaPicker/MediaPickerViewModel.swift +++ b/Sources/ExyteMediaPicker/Screens/MediaPicker/MediaPickerViewModel.swift @@ -4,7 +4,6 @@ import Foundation import SwiftUI -import Combine @MainActor final class MediaPickerViewModel: ObservableObject { @@ -14,20 +13,17 @@ final class MediaPickerViewModel: ObservableObject { @Published var pickedMediaUrl: URL? #endif + @Published private(set) var defaultAlbumsProvider = DefaultAlbumsProvider() @Published private(set) var internalPickerMode: MediaPickerMode = .photos - @Published private(set) var albums: [AlbumModel] = [] + + var albums: [AlbumModel] { + defaultAlbumsProvider.albums + } var shouldUpdatePickerMode: (MediaPickerMode)->() = {_ in} - let defaultAlbumsProvider = DefaultAlbumsProvider() - private let watcher = PhotoLibraryChangePermissionWatcher() - private var albumsCancellable: AnyCancellable? - func onStart() { defaultAlbumsProvider.reload() - albumsCancellable = defaultAlbumsProvider.albums.sink { [weak self] albums in - self?.albums = albums - } } func getAlbumModel(_ album: Album) -> AlbumModel? { diff --git a/Sources/ExyteMediaPicker/Extensions/Bundle+.swift b/Sources/ExyteMediaPicker/Theme/Bundle+.swift similarity index 88% rename from Sources/ExyteMediaPicker/Extensions/Bundle+.swift rename to Sources/ExyteMediaPicker/Theme/Bundle+.swift index 9d1f8b2..b5f0ae3 100644 --- a/Sources/ExyteMediaPicker/Extensions/Bundle+.swift +++ b/Sources/ExyteMediaPicker/Theme/Bundle+.swift @@ -1,5 +1,5 @@ // -// File.swift +// Bundle+.swift // // // Created by Alex.M on 07.07.2022. @@ -19,7 +19,7 @@ private final class BundleToken { private init() {} } -extension Bundle { +public extension Bundle { static var current: Bundle { BundleToken.bundle } diff --git a/Sources/ExyteMediaPicker/Theme/MediaPickerTheme.swift b/Sources/ExyteMediaPicker/Theme/MediaPickerTheme.swift new file mode 100644 index 0000000..e7dc723 --- /dev/null +++ b/Sources/ExyteMediaPicker/Theme/MediaPickerTheme.swift @@ -0,0 +1,156 @@ +// +// Created by Alex.M on 06.07.2022. +// + +import Foundation +import SwiftUI + +public struct MediaPickerTheme: Sendable { + public let main: Main + public let selection: Selection + public let cellStyle: CellStyle + public let error: Error + public let defaultHeader: DefaultHeader + + public init(main: MediaPickerTheme.Main = .init(), + selection: MediaPickerTheme.Selection = .init(), + cellStyle: MediaPickerTheme.CellStyle = .init(), + error: MediaPickerTheme.Error = .init(), + defaultHeader: MediaPickerTheme.DefaultHeader = .init()) { + self.main = main + self.selection = selection + self.cellStyle = cellStyle + self.error = error + self.defaultHeader = defaultHeader + } +} + +extension MediaPickerTheme { + public struct Main: Sendable { + public let pickerText: Color + public let pickerBackground: Color + public let fullscreenPhotoBackground: Color + public let cameraText: Color + public let cameraBackground: Color + public let cameraSelectionText: Color + public let cameraSelectionBackground: Color + + public init( + pickerText: Color = Color("pickerText", bundle: .current), + pickerBackground: Color = Color("pickerBG", bundle: .current), + fullscreenPhotoBackground: Color = Color("pickerBG", bundle: .current), + cameraText: Color = Color("cameraText", bundle: .current), + cameraBackground: Color = Color("cameraBG", bundle: .current), + cameraSelectionText: Color = Color("cameraText", bundle: .current), + cameraSelectionBackground: Color = Color("cameraBG", bundle: .current) + ) { + self.pickerText = pickerText + self.pickerBackground = pickerBackground + self.fullscreenPhotoBackground = fullscreenPhotoBackground + self.cameraText = cameraText + self.cameraBackground = cameraBackground + self.cameraSelectionText = cameraSelectionText + self.cameraSelectionBackground = cameraSelectionBackground + } + } + + public struct Selection: Sendable { + public let cellEmptyBorder: Color + public let cellEmptyBackground: Color + public let cellSelectedBorder: Color + public let cellSelectedBackground: Color + public let cellSelectedCheckmark: Color + public let fullscreenEmptyBorder: Color + public let fullscreenEmptyBackground: Color + public let fullscreenSelectedBorder: Color + public let fullscreenSelectedBackground: Color + public let fullscreenSelectedCheckmark: Color + + public init( + cellEmptyBorder: Color = .white, + cellEmptyBackground: Color = .black.opacity(0.25), + cellSelectedBorder: Color = .white, + cellSelectedBackground: Color = Color("selection", bundle: .current), + cellSelectedCheckmark: Color = .white, + fullscreenEmptyBorder: Color = Color("selection", bundle: .current), + fullscreenEmptyBackground: Color = .clear, + fullscreenSelectedBorder: Color = Color("selection", bundle: .current), + fullscreenSelectedBackground: Color = Color("selection", bundle: .current), + fullscreenSelectedCheckmark: Color = .white + ) { + self.cellEmptyBorder = cellEmptyBorder + self.cellEmptyBackground = cellEmptyBackground + self.cellSelectedBorder = cellSelectedBorder + self.cellSelectedBackground = cellSelectedBackground + self.cellSelectedCheckmark = cellSelectedCheckmark + self.fullscreenEmptyBorder = fullscreenEmptyBorder + self.fullscreenEmptyBackground = fullscreenEmptyBackground + self.fullscreenSelectedBorder = fullscreenSelectedBorder + self.fullscreenSelectedBackground = fullscreenSelectedBackground + self.fullscreenSelectedCheckmark = fullscreenSelectedCheckmark + } + + public init( + accent: Color, + tint: Color = .white, + background: Color = .black.opacity(0.25) + ) { + self.init( + cellEmptyBorder: tint, + cellEmptyBackground: background, + cellSelectedBorder: tint, + cellSelectedBackground: accent, + cellSelectedCheckmark: tint, + fullscreenEmptyBorder: accent, + fullscreenEmptyBackground: .clear, + fullscreenSelectedBorder: accent, + fullscreenSelectedBackground: accent, + fullscreenSelectedCheckmark: tint + ) + } + } + + public struct CellStyle: Sendable { + public let columnsSpacing: CGFloat + public let rowSpacing: CGFloat + public let cornerRadius: CGFloat + + public init(columnsSpacing: CGFloat = 1, + rowSpacing: CGFloat = 1, + cornerRadius: CGFloat = 0) { + self.columnsSpacing = columnsSpacing + self.rowSpacing = rowSpacing + self.cornerRadius = cornerRadius + } + } + + public struct Error: Sendable { + public let background: Color + public let tint: Color + + public init(background: Color = .red.opacity(0.7), + tint: Color = Color("cameraText", bundle: .current)) { + self.background = background + self.tint = tint + } + } + + public struct DefaultHeader: Sendable { + public let background: Color + + public init(background: Color = Color("pickerBG", bundle: .current), + segmentTintColor: Color = Color("pickerBG", bundle: .current), + selectedSegmentTintColor: Color = Color("pickerBG", bundle: .current), + selectedText: Color = Color("pickerText", bundle: .current), + unselectedText: Color = Color("pickerText", bundle: .current)) { + self.background = background + + DispatchQueue.main.async { + UISegmentedControl.appearance().backgroundColor = UIColor(segmentTintColor) + UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(selectedSegmentTintColor) + UISegmentedControl.appearance().setTitleTextAttributes([.foregroundColor: UIColor(selectedText)], for: .selected) + UISegmentedControl.appearance().setTitleTextAttributes([.foregroundColor: UIColor(unselectedText)], for: .normal) + } + } + } +} diff --git a/Sources/ExyteMediaPicker/Utils/Errors/PermissionActionView.swift b/Sources/ExyteMediaPicker/Utils/Errors/PermissionActionView.swift new file mode 100644 index 0000000..2a21bee --- /dev/null +++ b/Sources/ExyteMediaPicker/Utils/Errors/PermissionActionView.swift @@ -0,0 +1,79 @@ +// +// Created by Alex.M on 06.06.2022. +// + +import Foundation +import SwiftUI + +struct PermissionActionView: View { + + enum PermissionType { + case library(PermissionsService.PhotoLibraryPermissionStatus) + case camera(PermissionsService.CameraPermissionStatus) + } + + let type: PermissionType + + @State private var showSheet = false + + var body: some View { + ZStack { + if showSheet { + LimitedLibraryPickerProxyView(isPresented: $showSheet) { + DispatchQueue.main.async { + PermissionsService.shared.updatePhotoLibraryAuthorizationStatus() + } + } + .frame(width: 1, height: 1) + } + + switch type { + case .library(let status): + buildLibraryActionView(status) + case .camera(let status): + buildCameraActionView(status) + } + } + } +} + +private extension PermissionActionView { + + @ViewBuilder + func buildLibraryActionView(_ status: PermissionsService.PhotoLibraryPermissionStatus) -> some View { + switch status { + case .authorized, .unknown: + EmptyView() + case .limited: + PermissionsErrorView(text: "Setup Photos access to see more photos here") { + showSheet = true + } + case .unavailable: + goToSettingsButton(text: "Allow Photos access in settings to see photos here") + } + } + + @ViewBuilder + func buildCameraActionView(_ status: PermissionsService.CameraPermissionStatus) -> some View { + switch status { + case .authorized, .unknown: + EmptyView() + case .unavailable: + goToSettingsButton(text: "Allow Camera access in settings to see live preview") + } + } + + func goToSettingsButton(text: String) -> some View { + PermissionsErrorView( + text: text, + action: { + DispatchQueue.main.async { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } + } + ) + } +} diff --git a/Sources/ExyteMediaPicker/Views/Widgets/Errors/PermissionsErrorView.swift b/Sources/ExyteMediaPicker/Utils/Errors/PermissionsErrorView.swift similarity index 96% rename from Sources/ExyteMediaPicker/Views/Widgets/Errors/PermissionsErrorView.swift rename to Sources/ExyteMediaPicker/Utils/Errors/PermissionsErrorView.swift index 8a3f9c0..31b2468 100644 --- a/Sources/ExyteMediaPicker/Views/Widgets/Errors/PermissionsErrorView.swift +++ b/Sources/ExyteMediaPicker/Utils/Errors/PermissionsErrorView.swift @@ -30,5 +30,6 @@ struct PermissionsErrorView: View { .background(theme.error.background) .cornerRadius(5) .padding(.horizontal, 20) + .padding(.bottom, 6) } } diff --git a/Sources/ExyteMediaPicker/Utils/Extensions/Collection+.swift b/Sources/ExyteMediaPicker/Utils/Extensions/Collection+.swift new file mode 100644 index 0000000..104ad59 --- /dev/null +++ b/Sources/ExyteMediaPicker/Utils/Extensions/Collection+.swift @@ -0,0 +1,12 @@ +// +// File.swift +// ExyteMediaPicker +// +// Created by Alisa Mylnikova on 25.02.2025. +// + +extension Collection { + subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Sources/ExyteMediaPicker/Utils/Extensions/ColumnCalculation.swift b/Sources/ExyteMediaPicker/Utils/Extensions/ColumnCalculation.swift new file mode 100644 index 0000000..054e111 --- /dev/null +++ b/Sources/ExyteMediaPicker/Utils/Extensions/ColumnCalculation.swift @@ -0,0 +1,18 @@ +// +// Untitled.swift +// ExyteMediaPicker +// +// Created by Alisa Mylnikova on 25.03.2025. +// + +import SwiftUI + +@MainActor +func calculateColumnWidth(spacing: CGFloat) -> (CGFloat, [GridItem]) { + let gridWidth = UIScreen.main.bounds.width + let wholeCount = 3.0 + let noSpaces = gridWidth - spacing * (wholeCount - 1) + let columnWidth = noSpaces / wholeCount + let columns = Array(repeating: GridItem(.fixed(columnWidth), spacing: spacing, alignment: .top), count: Int(wholeCount)) + return (columnWidth, columns) +} diff --git a/Sources/ExyteMediaPicker/Extensions/OrientationTransformationExtensions.swift b/Sources/ExyteMediaPicker/Utils/Extensions/OrientationTransformationExtensions.swift similarity index 64% rename from Sources/ExyteMediaPicker/Extensions/OrientationTransformationExtensions.swift rename to Sources/ExyteMediaPicker/Utils/Extensions/OrientationTransformationExtensions.swift index 9125cc2..ccacf6c 100644 --- a/Sources/ExyteMediaPicker/Extensions/OrientationTransformationExtensions.swift +++ b/Sources/ExyteMediaPicker/Utils/Extensions/OrientationTransformationExtensions.swift @@ -21,15 +21,3 @@ extension UIImage.Orientation { static var `default`: UIImage.Orientation { .right } } - -extension AVCaptureVideoOrientation { - - init(_ orientation: UIDeviceOrientation) { - switch orientation { - case .landscapeLeft: self = .landscapeRight - case .landscapeRight: self = .landscapeLeft - default: self = .portrait - } - } - -} diff --git a/Sources/ExyteMediaPicker/Extensions/Sequence+asyncMap.swift b/Sources/ExyteMediaPicker/Utils/Extensions/Sequence+asyncMap.swift similarity index 100% rename from Sources/ExyteMediaPicker/Extensions/Sequence+asyncMap.swift rename to Sources/ExyteMediaPicker/Utils/Extensions/Sequence+asyncMap.swift diff --git a/Sources/ExyteMediaPicker/Extensions/TimeInterval+Duration.swift b/Sources/ExyteMediaPicker/Utils/Extensions/TimeInterval+Duration.swift similarity index 100% rename from Sources/ExyteMediaPicker/Extensions/TimeInterval+Duration.swift rename to Sources/ExyteMediaPicker/Utils/Extensions/TimeInterval+Duration.swift diff --git a/Sources/ExyteMediaPicker/Utils/Extensions/View+.swift b/Sources/ExyteMediaPicker/Utils/Extensions/View+.swift new file mode 100644 index 0000000..5fc3791 --- /dev/null +++ b/Sources/ExyteMediaPicker/Utils/Extensions/View+.swift @@ -0,0 +1,42 @@ +// +// File.swift +// +// +// Created by Alisa Mylnikova on 04.09.2023. +// + +import SwiftUI + +extension View { + func dismissKeyboard() { + DispatchQueue.main.async { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + } +} + +extension Shape { + func styled(_ foregroundColor: Color, border borderColor: Color = .clear, _ borderWidth: CGFloat = 0) -> some View { + self.foregroundStyle(foregroundColor) // Apply foreground color + .overlay { + self + .stroke(borderColor, lineWidth: borderWidth) // Apply border color and width + } + } +} + +extension View { + func padding(_ horizontal: CGFloat, _ vertical: CGFloat) -> some View { + self.padding(.horizontal, horizontal) + .padding(.vertical, vertical) + } + + @ViewBuilder + func applyIf(_ condition: Bool, apply: (Self) -> T) -> some View { + if condition { + apply(self) + } else { + self + } + } +} diff --git a/Sources/ExyteMediaPicker/Utils/Extensions/View+NotificationCenter.swift b/Sources/ExyteMediaPicker/Utils/Extensions/View+NotificationCenter.swift new file mode 100644 index 0000000..fe38084 --- /dev/null +++ b/Sources/ExyteMediaPicker/Utils/Extensions/View+NotificationCenter.swift @@ -0,0 +1,15 @@ +// +// View+NotificationCenter.swift +// +// +// Created by Alexandra Afonasova on 17.10.2022. +// + +import SwiftUI + +extension View { + @MainActor func onRotate(perform: @escaping (UIDeviceOrientation) -> Void) -> some View { + let publisher = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification) + return onReceive(publisher) { _ in perform(UIDevice.current.orientation) } + } +} diff --git a/Sources/ExyteMediaPicker/Extensions/Zoom+ScrollView.swift b/Sources/ExyteMediaPicker/Utils/Extensions/Zoom+ScrollView.swift similarity index 100% rename from Sources/ExyteMediaPicker/Extensions/Zoom+ScrollView.swift rename to Sources/ExyteMediaPicker/Utils/Extensions/Zoom+ScrollView.swift diff --git a/Sources/ExyteMediaPicker/Utils/Modifiers/KeyboardHeightHelper.swift b/Sources/ExyteMediaPicker/Utils/Modifiers/KeyboardHeightHelper.swift new file mode 100644 index 0000000..92ca3d6 --- /dev/null +++ b/Sources/ExyteMediaPicker/Utils/Modifiers/KeyboardHeightHelper.swift @@ -0,0 +1,64 @@ +// +// KeyboardHeightHelper.swift +// Example-iOS +// +// Created by Alisa Mylnikova on 23.08.2023. +// + +import SwiftUI + +#if compiler(>=6.0) +extension Notification: @retroactive @unchecked Sendable { } +#else +extension Notification: @unchecked Sendable { } +#endif + +@MainActor +class KeyboardHeightHelper: ObservableObject { + + static let shared = KeyboardHeightHelper() + + @Published var keyboardHeight: CGFloat = 0 + @Published var keyboardDisplayed: Bool = false + + init() { + self.listenForKeyboardNotifications() + } + + private func listenForKeyboardNotifications() { + NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { [weak self] notification in + guard let self = self else { return } + + Task { @MainActor in + // Safely extract the data from the notification on the main actor + guard let userInfo = notification.userInfo, + let keyboardRect = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } + + // Now update the UI on the main actor + self.keyboardHeight = keyboardRect.height + self.keyboardDisplayed = true + } + } + + NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { (notification) in + DispatchQueue.main.async { + self.keyboardHeight = 0 + } + } + + NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidHideNotification, object: nil, queue: .main) { (notification) in + DispatchQueue.main.async { + self.keyboardDisplayed = false + } + } + } + + private func handleKeyboardWillShow(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let keyboardRect = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { + return + } + self.keyboardHeight = keyboardRect.height + self.keyboardDisplayed = true + } +} diff --git a/Sources/ExyteMediaPicker/Modifiers/MediaButtonStyle.swift b/Sources/ExyteMediaPicker/Utils/Modifiers/MediaButtonStyle.swift similarity index 100% rename from Sources/ExyteMediaPicker/Modifiers/MediaButtonStyle.swift rename to Sources/ExyteMediaPicker/Utils/Modifiers/MediaButtonStyle.swift diff --git a/Sources/ExyteMediaPicker/Utils/Modifiers/MediaPickerThemeModifier.swift b/Sources/ExyteMediaPicker/Utils/Modifiers/MediaPickerThemeModifier.swift new file mode 100644 index 0000000..99d3233 --- /dev/null +++ b/Sources/ExyteMediaPicker/Utils/Modifiers/MediaPickerThemeModifier.swift @@ -0,0 +1,52 @@ +// +// Created by Alex.M on 06.07.2022. +// + +import Foundation +import SwiftUI + +public extension EnvironmentValues { + #if swift(>=6.0) + @Entry var mediaPickerTheme = MediaPickerTheme() + @Entry var mediaPickerThemeIsOverridden = false + #else + var mediaPickerTheme: MediaPickerTheme { + get { self[MediaPickerThemeKey.self] } + set { self[MediaPickerThemeKey.self] = newValue } + } + + var mediaPickerThemeIsOverridden: Bool { + get { self[MediaPickerThemeIsOverriddenKey.self] } + set { self[MediaPickerThemeIsOverriddenKey.self] = newValue } + } + #endif +} + +// Define keys only for older versions +#if swift(<6.0) +@preconcurrency public struct MediaPickerThemeKey: EnvironmentKey { + public static let defaultValue = MediaPickerTheme() +} + +public struct MediaPickerThemeIsOverriddenKey: EnvironmentKey { + public static let defaultValue = false +} +#endif + +public extension View { + func mediaPickerTheme(_ theme: MediaPickerTheme) -> some View { + self.environment(\.mediaPickerTheme, theme) + .environment(\.mediaPickerThemeIsOverridden, true) + } + + func mediaPickerTheme( + main: MediaPickerTheme.Main = .init(), + selection: MediaPickerTheme.Selection = .init(), + cellStyle: MediaPickerTheme.CellStyle = .init(), + error: MediaPickerTheme.Error = .init(), + defaultHeader: MediaPickerTheme.DefaultHeader = .init() + ) -> some View { + self.environment(\.mediaPickerTheme, MediaPickerTheme(main: main, selection: selection, cellStyle: cellStyle, error: error, defaultHeader: defaultHeader)) + .environment(\.mediaPickerThemeIsOverridden, true) + } +} diff --git a/Sources/ExyteMediaPicker/Modifiers/SafeAreaEnvironmentValues.swift b/Sources/ExyteMediaPicker/Utils/Modifiers/SafeAreaEnvironmentValues.swift similarity index 65% rename from Sources/ExyteMediaPicker/Modifiers/SafeAreaEnvironmentValues.swift rename to Sources/ExyteMediaPicker/Utils/Modifiers/SafeAreaEnvironmentValues.swift index 087ad6e..a5a1b61 100644 --- a/Sources/ExyteMediaPicker/Modifiers/SafeAreaEnvironmentValues.swift +++ b/Sources/ExyteMediaPicker/Utils/Modifiers/SafeAreaEnvironmentValues.swift @@ -21,28 +21,14 @@ extension UIApplication { $0.isKeyWindow } } -} -private struct SafeAreaInsetsKey: EnvironmentKey { - static var defaultValue: EdgeInsets { + static var safeArea: EdgeInsets { UIApplication.shared.keyWindow?.safeAreaInsets.swiftUiInsets ?? EdgeInsets() } } -extension EnvironmentValues { - var safeAreaInsets: EdgeInsets { - self[SafeAreaInsetsKey.self] - } -} - private extension UIEdgeInsets { var swiftUiInsets: EdgeInsets { EdgeInsets(top: top, leading: left, bottom: bottom, trailing: right) } } - -private extension UIEdgeInsets { - var insets: EdgeInsets { - EdgeInsets(top: top, leading: left, bottom: bottom, trailing: right) - } -} diff --git a/Sources/ExyteMediaPicker/Views/Widgets/Thumbnail/ThumbnailPlaceholder.swift b/Sources/ExyteMediaPicker/Utils/Thumbnail/ThumbnailPlaceholder.swift similarity index 100% rename from Sources/ExyteMediaPicker/Views/Widgets/Thumbnail/ThumbnailPlaceholder.swift rename to Sources/ExyteMediaPicker/Utils/Thumbnail/ThumbnailPlaceholder.swift diff --git a/Sources/ExyteMediaPicker/Views/Widgets/Thumbnail/ThumbnailView.swift b/Sources/ExyteMediaPicker/Utils/Thumbnail/ThumbnailView.swift similarity index 55% rename from Sources/ExyteMediaPicker/Views/Widgets/Thumbnail/ThumbnailView.swift rename to Sources/ExyteMediaPicker/Utils/Thumbnail/ThumbnailView.swift index c1b1adb..76566ff 100644 --- a/Sources/ExyteMediaPicker/Views/Widgets/Thumbnail/ThumbnailView.swift +++ b/Sources/ExyteMediaPicker/Utils/Thumbnail/ThumbnailView.swift @@ -8,19 +8,18 @@ struct ThumbnailView: View { #if os(iOS) let preview: UIImage? + let size: CGFloat #else // FIXME: Create preview for image/video for other platforms #endif var body: some View { if let preview = preview { - GeometryReader { proxy in - Image(uiImage: preview) - .resizable() - .scaledToFill() - .frame(width: proxy.size.width, height: proxy.size.height) - .clipped() - } + Image(uiImage: preview) + .resizable() + .scaledToFill() + .frame(width: size, height: size) + .clipped() } else { ThumbnailPlaceholder() } diff --git a/Sources/ExyteMediaPicker/Utils/Widgets/AsyncButton.swift b/Sources/ExyteMediaPicker/Utils/Widgets/AsyncButton.swift new file mode 100644 index 0000000..58f8fac --- /dev/null +++ b/Sources/ExyteMediaPicker/Utils/Widgets/AsyncButton.swift @@ -0,0 +1,23 @@ +// +// SwiftUIView.swift +// +// +// Created by Alisa Mylnikova on 01.04.2025. +// + +import SwiftUI + +struct AsyncButton: View { + var action: () async -> () + var label: (()->Content) + + var body: some View { + Button { + Task { + await action() + } + } label: { + label() + } + } +} diff --git a/Sources/ExyteMediaPicker/Views/Widgets/CameraStubView.swift b/Sources/ExyteMediaPicker/Utils/Widgets/CameraStubView.swift similarity index 81% rename from Sources/ExyteMediaPicker/Views/Widgets/CameraStubView.swift rename to Sources/ExyteMediaPicker/Utils/Widgets/CameraStubView.swift index 76cd613..0285101 100644 --- a/Sources/ExyteMediaPicker/Views/Widgets/CameraStubView.swift +++ b/Sources/ExyteMediaPicker/Utils/Widgets/CameraStubView.swift @@ -7,8 +7,8 @@ import SwiftUI struct CameraStubView: View { - @Binding var isPresented: Bool - + let didPressCancel: () -> Void + var body: some View { ZStack { RoundedRectangle(cornerRadius: 20) @@ -22,17 +22,20 @@ struct CameraStubView: View { .font(.title3) .multilineTextAlignment(.center) Button("Close") { - isPresented = false + didPressCancel() } .padding() } + .foregroundStyle(.black) } } } struct CameraStubView_Preview: PreviewProvider { static var previews: some View { - CameraStubView(isPresented: .constant(true)) + CameraStubView { + debugPrint("close") + } } } diff --git a/Sources/ExyteMediaPicker/Utils/Widgets/LimitedLibraryPickerProxyView.swift b/Sources/ExyteMediaPicker/Utils/Widgets/LimitedLibraryPickerProxyView.swift new file mode 100644 index 0000000..c58ae28 --- /dev/null +++ b/Sources/ExyteMediaPicker/Utils/Widgets/LimitedLibraryPickerProxyView.swift @@ -0,0 +1,37 @@ +// +// Created by Alex.M on 02.06.2022. +// + +import SwiftUI +import UIKit +import PhotosUI + +@MainActor +struct LimitedLibraryPickerProxyView: UIViewControllerRepresentable { + @Binding var isPresented: Bool + var didDismiss: @Sendable ()->() + + func makeUIViewController(context: Context) -> UIViewController { + let controller = UIViewController() + + DispatchQueue.main.async { [controller] in + PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: controller) + trackCompletion(in: controller) + } + + return controller + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} + + func trackCompletion(in controller: UIViewController) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak controller] in + if controller?.presentedViewController == nil { + self.$isPresented.wrappedValue = false + self.didDismiss() + } else if let controller = controller { + self.trackCompletion(in: controller) + } + } + } +} diff --git a/Sources/ExyteMediaPicker/Utils/Widgets/MediasGrid.swift b/Sources/ExyteMediaPicker/Utils/Widgets/MediasGrid.swift new file mode 100644 index 0000000..3f21cae --- /dev/null +++ b/Sources/ExyteMediaPicker/Utils/Widgets/MediasGrid.swift @@ -0,0 +1,90 @@ +// +// Created by Alex.M on 06.06.2022. +// + +import Foundation +import SwiftUI + +public struct MediasGrid: View +where Element: Identifiable, Camera: View, Content: View, LoadingCell: View { + + public let data: [Element] + public let liveCameraCell: LiveCameraCellStyle + public let camera: () -> Camera + public let content: (Element, _ index: Int, _ size: CGFloat) -> Content + public let loadingCell: () -> LoadingCell + + @Environment(\.mediaPickerTheme) private var theme + + public init(_ data: [Element], + liveCameraCell: LiveCameraCellStyle, + @ViewBuilder camera: @escaping () -> Camera, + @ViewBuilder content: @escaping (Element, Int, CGFloat) -> Content, + @ViewBuilder loadingCell: @escaping () -> LoadingCell) { + self.data = data + self.liveCameraCell = liveCameraCell + self.camera = camera + self.content = content + self.loadingCell = loadingCell + } + + public var body: some View { + let (columnWidth, columns) = calculateColumnWidth( + spacing: theme.cellStyle.columnsSpacing + ) + let spacing = theme.cellStyle.rowSpacing + + switch liveCameraCell { + case .prominant: + let columnCount = columns.count + let indexedData = Array(data.enumerated()) + let itemsToTake = (columnCount - 1) * 2 + let topData = indexedData.prefix(itemsToTake) + let remainingData = indexedData.dropFirst(itemsToTake) + + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top, spacing: spacing) { + camera() + .frame(width: columnWidth, height: columnWidth * 2 + spacing) + .clipped() + + let topColumns = Array( + repeating: GridItem(.fixed(columnWidth), spacing: spacing,alignment: .top), + count: columnCount - 1 + ) + + LazyVGrid(columns: topColumns, spacing: spacing) { + ForEach(topData, id: \.element.id) { index, element in + content(element, index, columnWidth) + } + } + } + .padding(.bottom, spacing) + + LazyVGrid(columns: columns, spacing: spacing) { + ForEach(remainingData, id: \.element.id) { index, element in + content(element, index, columnWidth) + } + loadingCell() + } + } + + case .small: + LazyVGrid(columns: columns, spacing: theme.cellStyle.rowSpacing) { + camera() + ForEach(data.indices, id: \.self) { index in + content(data[index], index, columnWidth) + } + loadingCell() + } + + case .none: + LazyVGrid(columns: columns, spacing: spacing) { + ForEach(Array(data.enumerated()), id: \.element.id) { index, element in + content(element, index, columnWidth) + } + loadingCell() + } + } + } +} diff --git a/Sources/ExyteMediaPicker/Utils/Widgets/PlayerUIView.swift b/Sources/ExyteMediaPicker/Utils/Widgets/PlayerUIView.swift new file mode 100644 index 0000000..a2d1556 --- /dev/null +++ b/Sources/ExyteMediaPicker/Utils/Widgets/PlayerUIView.swift @@ -0,0 +1,68 @@ +// +// File.swift +// +// +// Created by Alisa Mylnikova on 04.09.2023. +// + +import SwiftUI +import AVFoundation + +struct PlayerView: UIViewRepresentable { + + var player: AVPlayer + var bgColor: Color + var useFill: Bool + + func makeUIView(context: Context) -> PlayerUIView { + PlayerUIView(player: player, bgColor: bgColor, useFill: useFill) + } + + func updateUIView(_ uiView: PlayerUIView, context: UIViewRepresentableContext) { + uiView.playerLayer.player = player + uiView.playerLayer.videoGravity = useFill ? .resizeAspectFill : .resizeAspect + } +} + +class PlayerUIView: UIView { + + // MARK: Class Property + + let playerLayer = AVPlayerLayer() + + // MARK: Init + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + init(player: AVPlayer, bgColor: Color, useFill: Bool) { + super.init(frame: .zero) + self.playerSetup(player: player, bgColor: bgColor, useFill: useFill) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: Life-Cycle + + override func layoutSubviews() { + super.layoutSubviews() + playerLayer.frame = bounds + } + + // MARK: Class Methods + + private func playerSetup(player: AVPlayer, bgColor: Color, useFill: Bool) { + playerLayer.player = player + playerLayer.videoGravity = useFill ? .resizeAspectFill : .resizeAspect + player.actionAtItemEnd = .none + layer.addSublayer(playerLayer) + playerLayer.backgroundColor = bgColor.cgColor + } +} diff --git a/Sources/ExyteMediaPicker/Utils/Widgets/SelectableView.swift b/Sources/ExyteMediaPicker/Utils/Widgets/SelectableView.swift new file mode 100644 index 0000000..ba1b795 --- /dev/null +++ b/Sources/ExyteMediaPicker/Utils/Widgets/SelectableView.swift @@ -0,0 +1,28 @@ +// +// Created by Alex.M on 27.05.2022. +// + +import SwiftUI + +struct SelectableView: View where Content: View { + + var selected: Int? + var isFullscreen: Bool + var canSelect: Bool + var selectionParamsHolder: SelectionParamsHolder + var onSelect: () -> Void + @ViewBuilder var content: () -> Content + + var body: some View { + content() + .overlay(alignment: .topTrailing) { + SelectionIndicatorView(index: selected, isFullscreen: isFullscreen, canSelect: canSelect, selectionParamsHolder: selectionParamsHolder) + .padding([.bottom, .leading], 10) // extend tappable area where possible + .contentShape(Rectangle()) + .onTapGesture { + onSelect() + } + .padding(2) + } + } +} diff --git a/Sources/ExyteMediaPicker/Utils/Widgets/SelectionIndicatorView.swift b/Sources/ExyteMediaPicker/Utils/Widgets/SelectionIndicatorView.swift new file mode 100644 index 0000000..8945bd0 --- /dev/null +++ b/Sources/ExyteMediaPicker/Utils/Widgets/SelectionIndicatorView.swift @@ -0,0 +1,77 @@ +// +// Created by Alex.M on 27.05.2022. +// + +import SwiftUI + +struct SelectionIndicatorView: View { + + @EnvironmentObject private var selectionService: SelectionService + + @Environment(\.mediaPickerTheme) var theme + + var index: Int? + var isFullscreen: Bool + var canSelect: Bool + var selectionParamsHolder: SelectionParamsHolder + + var size: CGFloat { isFullscreen ? 26 : 24 } + + var emptyBorder: Color { isFullscreen ? theme.selection.fullscreenEmptyBorder : theme.selection.cellEmptyBorder } + var emptyBackground: Color { isFullscreen ? theme.selection.fullscreenEmptyBackground : theme.selection.cellEmptyBackground } + var selectedBorder: Color { isFullscreen ? theme.selection.fullscreenSelectedBorder : theme.selection.cellSelectedBorder } + var selectedBackground: Color { isFullscreen ? theme.selection.fullscreenSelectedBackground : theme.selection.cellSelectedBackground } + var selectedCheckmark: Color { isFullscreen ? theme.selection.fullscreenSelectedCheckmark : theme.selection.cellSelectedCheckmark } + + var body: some View { + Group { + switch selectionParamsHolder.selectionStyle { + case .checkmark: + checkView + case .count: + countView + } + } + .frame(width: size, height: size) + } + + @ViewBuilder + var checkView: some View { + if canSelect { + let selected = index != nil + ZStack { + Circle().styled( + selected ? selectedBackground : emptyBackground, + border: selected ? selectedBorder : emptyBorder, 2 + ) + if index != nil { + Image(systemName: "checkmark") + .resizable() + .foregroundColor(selectedCheckmark) + .font(.system(size: 14, weight: .bold)) + .padding(7) + } + } + .animation(.easeOut(duration: 0.2), value: selected) + } + } + + @ViewBuilder + var countView: some View { + if canSelect { + let selected = index != nil + ZStack { + Circle().styled( + selected ? selectedBackground : emptyBackground, + border: selected ? selectedBorder : emptyBorder, 2 + ) + if let index { + Text("\(index + 1)") + .foregroundColor(selectedCheckmark) + .font(.system(size: 14, weight: .bold)) + } + } + .animation(.easeOut(duration: 0.2), value: selected) + } + } +} diff --git a/Sources/ExyteMediaPicker/Views/Pages/AlbumView/AlbumView.swift b/Sources/ExyteMediaPicker/Views/Pages/AlbumView/AlbumView.swift deleted file mode 100644 index d47564d..0000000 --- a/Sources/ExyteMediaPicker/Views/Pages/AlbumView/AlbumView.swift +++ /dev/null @@ -1,119 +0,0 @@ -// -// Created by Alex.M on 27.05.2022. -// - -import SwiftUI - -struct AlbumView: View { - - @EnvironmentObject private var selectionService: SelectionService - @EnvironmentObject private var permissionsService: PermissionsService - @Environment(\.mediaPickerTheme) private var theme - - @StateObject var viewModel: AlbumViewModel - @Binding var showingCamera: Bool - @Binding var currentFullscreenMedia: Media? - var shouldShowCamera: Bool - var shouldShowLoadingCell: Bool - var selectionParamsHolder: SelectionParamsHolder - - @State private var fullscreenItem: AssetMediaModel? { - didSet { - if let item = fullscreenItem { - currentFullscreenMedia = Media(source: item) - } else { - currentFullscreenMedia = nil - } - } - } - - var body: some View { - if let title = viewModel.title { - content.navigationTitle(title) - } else { - content - } - } -} - -private extension AlbumView { - - @ViewBuilder - var content: some View { - ScrollView { - VStack { - if let action = permissionsService.photoLibraryAction { - PermissionsActionView(action: .library(action)) - } - if shouldShowCamera, let action = permissionsService.cameraAction { - PermissionsActionView(action: .camera(action)) - } - if viewModel.isLoading { - ProgressView() - } else if viewModel.assetMediaModels.isEmpty, !shouldShowLoadingCell { - Text("Empty data") - .font(.title3) - } else { - MediasGrid(viewModel.assetMediaModels) { - if shouldShowCamera && permissionsService.cameraAction == nil { - LiveCameraCell { - showingCamera = true - } - } - } content: { assetMediaModel in - let index = selectionService.index(of: assetMediaModel) - SelectableView(selected: index, isFullscreen: false, canSelect: selectionService.canSelect(assetMediaModel: assetMediaModel), selectionParamsHolder: selectionParamsHolder) { - selectionService.onSelect(assetMediaModel: assetMediaModel) - } content: { - Button { - if fullscreenItem == nil { - fullscreenItem = assetMediaModel - } - } label: { - MediaCell(viewModel: MediaViewModel(assetMediaModel: assetMediaModel)) - } - .buttonStyle(MediaButtonStyle()) - .contentShape(Rectangle()) - } - } loadingCell: { - if shouldShowLoadingCell { - ZStack { - Color.white.opacity(0.5) - ProgressView() - } - .aspectRatio(1, contentMode: .fit) - } - } - .onChange(of: viewModel.assetMediaModels) { newValue in - selectionService.updateSelection(with: newValue) - } - } - - Spacer() - } - .frame(maxWidth: .infinity) - } - .background(theme.main.albumSelectionBackground) - .overlay { - if let item = fullscreenItem { - FullscreenContainer( - isPresented: fullscreenPresentedBinding(), - assetMediaModels: viewModel.assetMediaModels, - selection: item.id, - selectionParamsHolder: selectionParamsHolder - ) - } - } - } - - func fullscreenPresentedBinding() -> Binding { - Binding( - get: { fullscreenItem != nil }, - set: { value in - if value == false { - fullscreenItem = nil - } - } - ) - } -} diff --git a/Sources/ExyteMediaPicker/Views/Pages/AlbumView/AlbumViewModel.swift b/Sources/ExyteMediaPicker/Views/Pages/AlbumView/AlbumViewModel.swift deleted file mode 100644 index 01b1c9b..0000000 --- a/Sources/ExyteMediaPicker/Views/Pages/AlbumView/AlbumViewModel.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// Created by Alex.M on 09.06.2022. -// - -import Foundation -import Combine - -final class AlbumViewModel: ObservableObject { - - @Published var title: String? = nil - @Published var assetMediaModels: [AssetMediaModel] = [] - @Published var isLoading: Bool = false - - let mediasProvider: MediasProviderProtocol - - private var mediaCancellable: AnyCancellable? - - init(mediasProvider: MediasProviderProtocol) { - self.mediasProvider = mediasProvider - onStart() - } - - func onStart() { - isLoading = true - mediaCancellable = mediasProvider.assetMediaModelsPublisher - .receive(on: RunLoop.main) - .sink { [weak self] in - self?.assetMediaModels = $0 - self?.isLoading = false - } - - mediasProvider.reload() - } - - deinit { - mediasProvider.cancel() - mediaCancellable = nil - } -} diff --git a/Sources/ExyteMediaPicker/Views/Pages/AlbumsView/AlbumsViewModel.swift b/Sources/ExyteMediaPicker/Views/Pages/AlbumsView/AlbumsViewModel.swift deleted file mode 100644 index 3ac2be8..0000000 --- a/Sources/ExyteMediaPicker/Views/Pages/AlbumsView/AlbumsViewModel.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// Created by Alex.M on 07.06.2022. -// - -import Foundation -import Combine - -final class AlbumsViewModel: ObservableObject { - // MARK: - Values - // MARK: Public - @Published var albums: [AlbumModel] = [] - @Published var isLoading: Bool = false - - let albumsProvider: AlbumsProviderProtocol - - // MARK: Private - private var albumsCancellable: AnyCancellable? - - // MARK: - Object life cycle - init(albumsProvider: AlbumsProviderProtocol) { - self.albumsProvider = albumsProvider - } - - // MARK: - Public methods - func onStart() { - isLoading = true - albumsCancellable = albumsProvider.albums - .receive(on: DispatchQueue.main) - .sink { [weak self] in - self?.albums = $0 - self?.isLoading = false - } - - albumsProvider.reload() - } - - func onStop() { - albumsCancellable = nil - } -} diff --git a/Sources/ExyteMediaPicker/Views/Pages/Camera/CameraSelectionContainer.swift b/Sources/ExyteMediaPicker/Views/Pages/Camera/CameraSelectionContainer.swift deleted file mode 100644 index b78db92..0000000 --- a/Sources/ExyteMediaPicker/Views/Pages/Camera/CameraSelectionContainer.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// CameraSelectionContainer.swift -// -// -// Created by Alisa Mylnikova on 12.07.2022. -// - -import SwiftUI - -public struct CameraSelectionView: View { - - @EnvironmentObject private var cameraSelectionService: CameraSelectionService - - @State private var index: Int = 0 - - public var body: some View { - TabView(selection: $index) { - ForEach(cameraSelectionService.selected.enumerated().map({ $0 }), id: \.offset) { (index, mediaModel) in - FullscreenCell(viewModel: FullscreenCellViewModel(mediaModel: mediaModel)) - .tag(index) - .frame(maxHeight: .infinity) - .padding(.vertical) - } - } - .tabViewStyle(.page(indexDisplayMode: .never)) - } -} - -struct DefaultCameraSelectionContainer: View { - - @EnvironmentObject private var cameraSelectionService: CameraSelectionService - @Environment(\.mediaPickerTheme) private var theme - - @ObservedObject var viewModel: MediaPickerViewModel - - @Binding var showingPicker: Bool - - var body: some View { - VStack { - HStack { - Button("Cancel") { - viewModel.onCancelCameraSelection(cameraSelectionService.hasSelected) - } - .foregroundColor(.white) - Spacer() - } - .padding() - - CameraSelectionView() - - HStack { - Button("Done") { - showingPicker = false - } - Spacer() - Button { - viewModel.setPickerMode(.camera) - } label: { - Image(systemName: "plus.app") - .resizable() - .frame(width: 30, height: 30) - } - } - .foregroundColor(.white) - .font(.system(size: 16)) - .padding() - } - .background(theme.main.cameraSelectionBackground) - } -} diff --git a/Sources/ExyteMediaPicker/Views/Pages/Fullscreen/FullscreenCell.swift b/Sources/ExyteMediaPicker/Views/Pages/Fullscreen/FullscreenCell.swift deleted file mode 100644 index 644982e..0000000 --- a/Sources/ExyteMediaPicker/Views/Pages/Fullscreen/FullscreenCell.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// Created by Alex.M on 09.06.2022. -// - -import Foundation -import SwiftUI -import AVKit - -struct FullscreenCell: View { - - @StateObject var viewModel: FullscreenCellViewModel - - var body: some View { - VStack { - if let image = viewModel.image { - ZoomableScrollView { - Image(uiImage: image) - .resizable() - .scaledToFit() - } - } else if let player = viewModel.player { - ZoomableScrollView { - VideoPlayer(player: player) - .disabled(true) - .overlay { - ZStack { - Color.clear - if !viewModel.isPlaying { - Image(systemName: "play.fill") - .resizable() - .frame(width: 50, height: 50) - .foregroundColor(.white.opacity(0.8)) - } - } - .contentShape(Rectangle()) - .onTapGesture { - viewModel.togglePlay() - } - } - } - } else { - Spacer() - ProgressView() - .tint(.white) - Spacer() - } - } - .task { - await viewModel.onStart() - } - .onDisappear { - viewModel.onStop() - } - } -} diff --git a/Sources/ExyteMediaPicker/Views/Pages/Fullscreen/FullscreenContainer.swift b/Sources/ExyteMediaPicker/Views/Pages/Fullscreen/FullscreenContainer.swift deleted file mode 100644 index 83654a1..0000000 --- a/Sources/ExyteMediaPicker/Views/Pages/Fullscreen/FullscreenContainer.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// Created by Alex.M on 09.06.2022. -// - -import Foundation -import SwiftUI - -struct FullscreenContainer: View { - - @EnvironmentObject private var selectionService: SelectionService - @Environment(\.mediaPickerTheme) private var theme - - @Binding var isPresented: Bool - let assetMediaModels: [AssetMediaModel] - @State var selection: AssetMediaModel.ID - var selectionParamsHolder: SelectionParamsHolder - - private var selectedMediaModel: AssetMediaModel? { - assetMediaModels.first { $0.id == selection } - } - - private var selectionServiceIndex: Int? { - guard let selectedMediaModel = selectedMediaModel else { - return nil - } - return selectionService.index(of: selectedMediaModel) - } - - var body: some View { - TabView(selection: $selection) { - ForEach(assetMediaModels, id: \.id) { assetMediaModel in - FullscreenCell(viewModel: FullscreenCellViewModel(mediaModel: assetMediaModel)) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .tag(assetMediaModel.id) - } - } - .overlay { - if let selectedMediaModel = selectedMediaModel { - SelectIndicatorView(index: selectionServiceIndex, isFullscreen: true, canSelect: selectionService.canSelect(assetMediaModel: selectedMediaModel), selectionParamsHolder: selectionParamsHolder) - .padding([.horizontal, .bottom], 20) - .contentShape(Rectangle()) - .onTapGesture { - selectionService.onSelect(assetMediaModel: selectedMediaModel) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - } - } - .onTapGesture { - if let selectedMediaModel = selectedMediaModel, selectedMediaModel.mediaType == .image { - selectionService.onSelect(assetMediaModel: selectedMediaModel) - } - } - .overlay(closeButton) - .tabViewStyle(.page(indexDisplayMode: .never)) - .background( - theme.main.fullscreenPhotoBackground - .ignoresSafeArea() - ) - } - - var closeButton: some View { - Button { - isPresented = false - } label: { - Image(systemName: "xmark") - .resizable() - .tint(theme.selection.fullscreenTint) - .frame(width: 20, height: 20) - } - .padding([.horizontal, .bottom], 20) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } -} diff --git a/Sources/ExyteMediaPicker/Views/Widgets/Errors/PermissionsActionView.swift b/Sources/ExyteMediaPicker/Views/Widgets/Errors/PermissionsActionView.swift deleted file mode 100644 index 65f585a..0000000 --- a/Sources/ExyteMediaPicker/Views/Widgets/Errors/PermissionsActionView.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// Created by Alex.M on 06.06.2022. -// - -import Foundation -import SwiftUI - -struct PermissionsActionView: View { - - let action: Action - - @State private var showSheet = false - - var body: some View { - ZStack { - if showSheet { - LimitedLibraryPickerProxyView(isPresented: $showSheet) { - NotificationCenter.default.post( - name: photoLibraryChangeLimitedPhotosNotification, - object: nil) - } - .frame(width: 1, height: 1) - } - - switch action { - case .library(let assetsLibraryAction): - buildLibraryAction(assetsLibraryAction) - case .camera(let cameraAction): - buildCameraAction(cameraAction) - } - } - } -} - -private extension PermissionsActionView { - - @ViewBuilder - func buildLibraryAction(_ action: PermissionsService.PhotoLibraryAction) -> some View { - switch action { - case .selectMore: - PermissionsErrorView(text: "Setup Photos access to see more photos here") { - showSheet = true - } - case .authorize: - goToSettingsButton(text: "Allow Photos access in settings to see photos here") - case .unavailable: - PermissionsErrorView(text: "Sorry, Photos are not available.", action: nil) - case .unknown: - fatalError("Unknown permission status.") - } - } - - @ViewBuilder - func buildCameraAction(_ action: PermissionsService.CameraAction) -> some View { - switch action { - case .authorize: - goToSettingsButton(text: "Allow Camera access in settings to see live preview") - case .unavailable: - PermissionsErrorView(text: "Sorry, Camera is not available.", action: nil) - case .unknown: - fatalError("Unknown permission status.") - } - } - - func goToSettingsButton(text: String) -> some View { - PermissionsErrorView( - text: text, - action: { - guard let url = URL(string: UIApplication.openSettingsURLString) - else { return } - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } - } - ) - } -} - -extension PermissionsActionView { - enum Action { - case library(PermissionsService.PhotoLibraryAction) - case camera(PermissionsService.CameraAction) - } -} diff --git a/Sources/ExyteMediaPicker/Views/Widgets/LimitedLibraryPickerProxyView.swift b/Sources/ExyteMediaPicker/Views/Widgets/LimitedLibraryPickerProxyView.swift deleted file mode 100644 index 3d1aa28..0000000 --- a/Sources/ExyteMediaPicker/Views/Widgets/LimitedLibraryPickerProxyView.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// Created by Alex.M on 02.06.2022. -// - -import SwiftUI -import UIKit -import PhotosUI - -struct LimitedLibraryPickerProxyView: UIViewControllerRepresentable { - @Binding var isPresented: Bool - var didDismiss: ()->() - - func makeUIViewController(context: Context) -> UIViewController { - let controller = UIViewController() - - DispatchQueue.main.async { [controller] in - PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: controller) - context.coordinator.trackCompletion(in: controller) - } - - return controller - } - - func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} - - func makeCoordinator() -> Coordinator { - Coordinator(isPresented: $isPresented, didDismiss: didDismiss) - } - - class Coordinator: NSObject { - private var isPresented: Binding - var didDismiss: ()->() - - init(isPresented: Binding, didDismiss: @escaping ()->()) { - self.isPresented = isPresented - self.didDismiss = didDismiss - } - - func trackCompletion(in controller: UIViewController) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self, weak controller] in - if controller?.presentedViewController == nil { - self?.isPresented.wrappedValue = false - self?.didDismiss() - } else if let controller = controller { - self?.trackCompletion(in: controller) - } - } - } - } -} diff --git a/Sources/ExyteMediaPicker/Views/Widgets/MediasGrid.swift b/Sources/ExyteMediaPicker/Views/Widgets/MediasGrid.swift deleted file mode 100644 index e21304d..0000000 --- a/Sources/ExyteMediaPicker/Views/Widgets/MediasGrid.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Created by Alex.M on 06.06.2022. -// - -import Foundation -import SwiftUI - -public struct MediasGrid: View where Data: RandomAccessCollection, Data.Element: Identifiable, Camera: View, Content: View, LoadingCell: View { - - public let data: Data - public let camera: () -> Camera - public let content: (Data.Element) -> Content - public let loadingCell: () -> LoadingCell - - private var columns: [GridItem] { - [GridItem(.adaptive(minimum: 100), spacing: 1, alignment: .top)] - } - - public init(_ data: Data, @ViewBuilder camera: @escaping () -> Camera, @ViewBuilder content: @escaping (Data.Element) -> Content, @ViewBuilder loadingCell: @escaping () -> LoadingCell) { - self.data = data - self.camera = camera - self.content = content - self.loadingCell = loadingCell - } - - public var body: some View { - LazyVGrid(columns: columns, spacing: 1) { - camera() - ForEach(data) { item in - content(item) - } - loadingCell() - } - } -} diff --git a/Sources/ExyteMediaPicker/Views/Widgets/SelectIndicatorView.swift b/Sources/ExyteMediaPicker/Views/Widgets/SelectIndicatorView.swift deleted file mode 100644 index 93cf4fe..0000000 --- a/Sources/ExyteMediaPicker/Views/Widgets/SelectIndicatorView.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// Created by Alex.M on 27.05.2022. -// - -import SwiftUI - -struct SelectIndicatorView: View { - - @EnvironmentObject private var selectionService: SelectionService - - @Environment(\.mediaPickerTheme) var theme - - var index: Int? - var isFullscreen: Bool - var canSelect: Bool - var selectionParamsHolder: SelectionParamsHolder - - var body: some View { - Group { - switch selectionParamsHolder.selectionStyle { - case .checkmark: - checkView - case .count: - countView - } - } - .frame(width: 24, height: 24) - } - - var checkView: some View { - Group { - if index != nil { - Image(systemName: "checkmark.circle.fill") - .resizable() - .foregroundColor(theme.selection.selectedTint) - .padding(2) - .background { - Circle() - .fill(theme.selection.selectedBackground) - } - } else if canSelect { - Image(systemName: "circle") - .resizable() - .foregroundColor(isFullscreen ? theme.selection.fullscreenTint : theme.selection.emptyTint) - .background { - Circle() - .fill(theme.selection.emptyBackground) - } - } - } - } - - var countView: some View { - Group { - if let index = index { - Image(systemName: "\(index + 1).circle.fill") - .resizable() - .foregroundColor(theme.selection.selectedTint) - .background { - Circle() - .fill(theme.selection.selectedBackground) - } - } else if canSelect { - Image(systemName: "circle") - .resizable() - .foregroundColor(isFullscreen ? theme.selection.fullscreenTint : theme.selection.emptyTint) - .background { - Circle() - .fill(theme.selection.emptyBackground) - } - } - } - } -} diff --git a/Sources/ExyteMediaPicker/Views/Widgets/SelectableView.swift b/Sources/ExyteMediaPicker/Views/Widgets/SelectableView.swift deleted file mode 100644 index 893b9eb..0000000 --- a/Sources/ExyteMediaPicker/Views/Widgets/SelectableView.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// Created by Alex.M on 27.05.2022. -// - -import SwiftUI - -struct SelectableView: View where Content: View { - - var selected: Int? - var paddings: CGFloat = 2 - var isFullscreen: Bool - var canSelect: Bool - var selectionParamsHolder: SelectionParamsHolder - var onSelect: () -> Void - @ViewBuilder var content: () -> Content - - var body: some View { - content().overlay { - Button { - onSelect() - } label: { - SelectIndicatorView(index: selected, isFullscreen: isFullscreen, canSelect: canSelect, selectionParamsHolder: selectionParamsHolder) - .padding([.bottom, .leading], 10) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - .padding(paddings) - } - } -}