diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 31d839843b..49bcb7dc84 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -754,7 +754,6 @@ 4C4AE6A1224AF35700D4AF6F /* SendMediaNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4AE69F224AF21900D4AF6F /* SendMediaNavigationController.swift */; }; 4C4AEC4520EC343B0020E72B /* DismissableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */; }; 4C4BC6C32102D697004040C9 /* ContactDiscoveryOperationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4BC6C22102D697004040C9 /* ContactDiscoveryOperationTest.swift */; }; - 4C4F5EBC22711EEB00F3DD01 /* SendMediaBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4F5EBB22711EEB00F3DD01 /* SendMediaBottomButton.swift */; }; 4C5250D221E7BD7D00CE3D95 /* PhoneNumberValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5250D121E7BD7D00CE3D95 /* PhoneNumberValidator.swift */; }; 4C5250D421E7C51900CE3D95 /* PhoneNumberValidatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5250D321E7C51900CE3D95 /* PhoneNumberValidatorTest.swift */; }; 4C63CC00210A620B003AE45C /* SignalTSan.supp in Resources */ = {isa = PBXBuildFile; fileRef = 4C63CBFF210A620B003AE45C /* SignalTSan.supp */; }; @@ -793,6 +792,7 @@ 768A1A2B17FC9CD300E00ED8 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 768A1A2A17FC9CD300E00ED8 /* libz.dylib */; }; 76C87F19181EFCE600C4ACAB /* MediaPlayer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */; }; 76EB054018170B33006006FC /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 76EB03C318170B33006006FC /* AppDelegate.m */; }; + 76FCCDBC27AB8FBE00BAA7F0 /* MediaControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76FCCDBB27AB8FBE00BAA7F0 /* MediaControls.swift */; }; 8806EF19248DBD7200E764C7 /* NotificationPermissionReminderMegaphone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8806EF18248DBD7200E764C7 /* NotificationPermissionReminderMegaphone.swift */; }; 8806EF1B248DBFC100E764C7 /* ContactPermissionReminderMegaphone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8806EF1A248DBFC100E764C7 /* ContactPermissionReminderMegaphone.swift */; }; 8809CE8722F8FE6D00D38867 /* AttachmentKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8809CE8622F8FE6D00D38867 /* AttachmentKeyboard.swift */; }; @@ -1943,7 +1943,6 @@ 4C4AE69F224AF21900D4AF6F /* SendMediaNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMediaNavigationController.swift; sourceTree = ""; }; 4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissableTextField.swift; sourceTree = ""; }; 4C4BC6C22102D697004040C9 /* ContactDiscoveryOperationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ContactDiscoveryOperationTest.swift; path = contact/ContactDiscoveryOperationTest.swift; sourceTree = ""; }; - 4C4F5EBB22711EEB00F3DD01 /* SendMediaBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMediaBottomButton.swift; sourceTree = ""; }; 4C5250D121E7BD7D00CE3D95 /* PhoneNumberValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneNumberValidator.swift; sourceTree = ""; }; 4C5250D321E7C51900CE3D95 /* PhoneNumberValidatorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneNumberValidatorTest.swift; sourceTree = ""; }; 4C63CBFF210A620B003AE45C /* SignalTSan.supp */ = {isa = PBXFileReference; lastKnownFileType = text; path = SignalTSan.supp; sourceTree = ""; }; @@ -1989,6 +1988,7 @@ 76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaPlayer.framework; path = System/Library/Frameworks/MediaPlayer.framework; sourceTree = SDKROOT; }; 76EB03C218170B33006006FC /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 76EB03C318170B33006006FC /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; + 76FCCDBB27AB8FBE00BAA7F0 /* MediaControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaControls.swift; sourceTree = ""; }; 7856A9F703AAD99E22B75A9B /* Pods-SignalShareExtension.profiling.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalShareExtension.profiling.xcconfig"; path = "Pods/Target Support Files/Pods-SignalShareExtension/Pods-SignalShareExtension.profiling.xcconfig"; sourceTree = ""; }; 7BB1CB6F2D7841356BE367EA /* Pods-Signal.testable release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Signal.testable release.xcconfig"; path = "Pods/Target Support Files/Pods-Signal/Pods-Signal.testable release.xcconfig"; sourceTree = ""; }; 7C5EABE2C09180BC71C4E097 /* Pods-SignalShareExtension.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalShareExtension.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SignalShareExtension/Pods-SignalShareExtension.app store release.xcconfig"; sourceTree = ""; }; @@ -2978,13 +2978,13 @@ children = ( 32C584A725B81C6600256804 /* AvatarViewController.swift */, 34969559219B605E00DCFE74 /* ImagePickerController.swift */, + 76FCCDBB27AB8FBE00BAA7F0 /* MediaControls.swift */, 4C21D5D7223AC60F00EF8A77 /* PhotoCapture.swift */, + E44AD4E524E98F430035D7B8 /* PhotoCaptureDismiss.swift */, 4CA485BA2232339F004B9E7D /* PhotoCaptureViewController.swift */, 3496955A219B605E00DCFE74 /* PhotoCollectionPickerController.swift */, 3496955B219B605E00DCFE74 /* PhotoLibrary.swift */, - 4C4F5EBB22711EEB00F3DD01 /* SendMediaBottomButton.swift */, 4C4AE69F224AF21900D4AF6F /* SendMediaNavigationController.swift */, - E44AD4E524E98F430035D7B8 /* PhotoCaptureDismiss.swift */, ); path = Photos; sourceTree = ""; @@ -6037,7 +6037,6 @@ 343A65981FC4CFE7000477A1 /* ConversationScrollButton.m in Sources */, 34386A51207D0C01009F5D9C /* HomeViewController.m in Sources */, 348EE28E25B897BF00814FC2 /* CVMediaCache.swift in Sources */, - 4C4F5EBC22711EEB00F3DD01 /* SendMediaBottomButton.swift in Sources */, 343417F12530A7480034FE0C /* CVComponentReactions.swift in Sources */, 88D1D40222EBB5A100F472C5 /* MessageRequestView.swift in Sources */, 45CD81EF1DC030E7004C9430 /* SyncPushTokensJob.swift in Sources */, @@ -6359,6 +6358,7 @@ 34ACA7DE2733159600E47AD4 /* OnboardingPhoneNumberDiscoverabilityViewController.swift in Sources */, 88A9729222FA5D4B004B4FBF /* AttachmentFormatPickerView.swift in Sources */, 346C19E125ACE9AE00061D3A /* MediaDownloadSettingsViewController.swift in Sources */, + 76FCCDBC27AB8FBE00BAA7F0 /* MediaControls.swift in Sources */, 8827004E23208A1900F01C46 /* AppearanceSettingsTableViewController.swift in Sources */, 344A761324B36C8C009D69A5 /* TestingViewController.swift in Sources */, 3495FF0D25F934C500959D6E /* PaymentsRestoreWalletCompleteViewController.swift in Sources */, diff --git a/Signal/Images.xcassets/ic_flash_mode_auto.imageset/Contents.json b/Signal/Images.xcassets/ic_flash_mode_auto.imageset/Contents.json deleted file mode 100644 index cb322d88de..0000000000 --- a/Signal/Images.xcassets/ic_flash_mode_auto.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "flash-auto-28@1x.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "flash-auto-28@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "flash-auto-28@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Signal/Images.xcassets/ic_flash_mode_auto.imageset/flash-auto-28@1x.png b/Signal/Images.xcassets/ic_flash_mode_auto.imageset/flash-auto-28@1x.png deleted file mode 100644 index 40e81ed985..0000000000 Binary files a/Signal/Images.xcassets/ic_flash_mode_auto.imageset/flash-auto-28@1x.png and /dev/null differ diff --git a/Signal/Images.xcassets/ic_flash_mode_auto.imageset/flash-auto-28@2x.png b/Signal/Images.xcassets/ic_flash_mode_auto.imageset/flash-auto-28@2x.png deleted file mode 100644 index abe8e2f5d2..0000000000 Binary files a/Signal/Images.xcassets/ic_flash_mode_auto.imageset/flash-auto-28@2x.png and /dev/null differ diff --git a/Signal/Images.xcassets/ic_flash_mode_auto.imageset/flash-auto-28@3x.png b/Signal/Images.xcassets/ic_flash_mode_auto.imageset/flash-auto-28@3x.png deleted file mode 100644 index 38b6ffb80f..0000000000 Binary files a/Signal/Images.xcassets/ic_flash_mode_auto.imageset/flash-auto-28@3x.png and /dev/null differ diff --git a/Signal/Images.xcassets/ic_flash_mode_off.imageset/Contents.json b/Signal/Images.xcassets/ic_flash_mode_off.imageset/Contents.json deleted file mode 100644 index 140ab9904a..0000000000 --- a/Signal/Images.xcassets/ic_flash_mode_off.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "flash-outline-28@1x.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "flash-outline-28@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "flash-outline-28@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Signal/Images.xcassets/ic_flash_mode_off.imageset/flash-outline-28@1x.png b/Signal/Images.xcassets/ic_flash_mode_off.imageset/flash-outline-28@1x.png deleted file mode 100644 index a132c89e20..0000000000 Binary files a/Signal/Images.xcassets/ic_flash_mode_off.imageset/flash-outline-28@1x.png and /dev/null differ diff --git a/Signal/Images.xcassets/ic_flash_mode_off.imageset/flash-outline-28@2x.png b/Signal/Images.xcassets/ic_flash_mode_off.imageset/flash-outline-28@2x.png deleted file mode 100644 index f0e8eef174..0000000000 Binary files a/Signal/Images.xcassets/ic_flash_mode_off.imageset/flash-outline-28@2x.png and /dev/null differ diff --git a/Signal/Images.xcassets/ic_flash_mode_off.imageset/flash-outline-28@3x.png b/Signal/Images.xcassets/ic_flash_mode_off.imageset/flash-outline-28@3x.png deleted file mode 100644 index ecfbb9bdee..0000000000 Binary files a/Signal/Images.xcassets/ic_flash_mode_off.imageset/flash-outline-28@3x.png and /dev/null differ diff --git a/Signal/Images.xcassets/ic_flash_mode_on.imageset/Contents.json b/Signal/Images.xcassets/ic_flash_mode_on.imageset/Contents.json deleted file mode 100644 index 54632ce264..0000000000 --- a/Signal/Images.xcassets/ic_flash_mode_on.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "flash-filled-28@1x.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "flash-filled-28@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "flash-filled-28@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Signal/Images.xcassets/ic_flash_mode_on.imageset/flash-filled-28@1x.png b/Signal/Images.xcassets/ic_flash_mode_on.imageset/flash-filled-28@1x.png deleted file mode 100644 index 55911ca8f1..0000000000 Binary files a/Signal/Images.xcassets/ic_flash_mode_on.imageset/flash-filled-28@1x.png and /dev/null differ diff --git a/Signal/Images.xcassets/ic_flash_mode_on.imageset/flash-filled-28@2x.png b/Signal/Images.xcassets/ic_flash_mode_on.imageset/flash-filled-28@2x.png deleted file mode 100644 index cf134711fc..0000000000 Binary files a/Signal/Images.xcassets/ic_flash_mode_on.imageset/flash-filled-28@2x.png and /dev/null differ diff --git a/Signal/Images.xcassets/ic_flash_mode_on.imageset/flash-filled-28@3x.png b/Signal/Images.xcassets/ic_flash_mode_on.imageset/flash-filled-28@3x.png deleted file mode 100644 index 48056f90a7..0000000000 Binary files a/Signal/Images.xcassets/ic_flash_mode_on.imageset/flash-filled-28@3x.png and /dev/null differ diff --git a/Signal/Images.xcassets/ic_switch_camera.imageset/Contents.json b/Signal/Images.xcassets/ic_switch_camera.imageset/Contents.json deleted file mode 100644 index c35436bc5b..0000000000 --- a/Signal/Images.xcassets/ic_switch_camera.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "switch-camera-28@1x.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "switch-camera-28@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "switch-camera-28@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Signal/Images.xcassets/ic_switch_camera.imageset/switch-camera-28@1x.png b/Signal/Images.xcassets/ic_switch_camera.imageset/switch-camera-28@1x.png deleted file mode 100644 index a22cad61e2..0000000000 Binary files a/Signal/Images.xcassets/ic_switch_camera.imageset/switch-camera-28@1x.png and /dev/null differ diff --git a/Signal/Images.xcassets/ic_switch_camera.imageset/switch-camera-28@2x.png b/Signal/Images.xcassets/ic_switch_camera.imageset/switch-camera-28@2x.png deleted file mode 100644 index 0fc0d562fb..0000000000 Binary files a/Signal/Images.xcassets/ic_switch_camera.imageset/switch-camera-28@2x.png and /dev/null differ diff --git a/Signal/Images.xcassets/ic_switch_camera.imageset/switch-camera-28@3x.png b/Signal/Images.xcassets/ic_switch_camera.imageset/switch-camera-28@3x.png deleted file mode 100644 index 5729ca1ed9..0000000000 Binary files a/Signal/Images.xcassets/ic_switch_camera.imageset/switch-camera-28@3x.png and /dev/null differ diff --git a/Signal/Images.xcassets/ic_x_with_shadow.imageset/Contents.json b/Signal/Images.xcassets/ic_x_with_shadow.imageset/Contents.json deleted file mode 100644 index 1f25af162c..0000000000 --- a/Signal/Images.xcassets/ic_x_with_shadow.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "x-24@1x.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "x-24@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "x-24@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Signal/Images.xcassets/ic_x_with_shadow.imageset/x-24@1x.png b/Signal/Images.xcassets/ic_x_with_shadow.imageset/x-24@1x.png deleted file mode 100644 index ee6d0b8874..0000000000 Binary files a/Signal/Images.xcassets/ic_x_with_shadow.imageset/x-24@1x.png and /dev/null differ diff --git a/Signal/Images.xcassets/ic_x_with_shadow.imageset/x-24@2x.png b/Signal/Images.xcassets/ic_x_with_shadow.imageset/x-24@2x.png deleted file mode 100644 index ea502285ca..0000000000 Binary files a/Signal/Images.xcassets/ic_x_with_shadow.imageset/x-24@2x.png and /dev/null differ diff --git a/Signal/Images.xcassets/ic_x_with_shadow.imageset/x-24@3x.png b/Signal/Images.xcassets/ic_x_with_shadow.imageset/x-24@3x.png deleted file mode 100644 index cbb9bb263d..0000000000 Binary files a/Signal/Images.xcassets/ic_x_with_shadow.imageset/x-24@3x.png and /dev/null differ diff --git a/Signal/Images.xcassets/media-composer-checkmark.imageset/Contents.json b/Signal/Images.xcassets/media-composer-checkmark.imageset/Contents.json new file mode 100644 index 0000000000..1732beebc1 --- /dev/null +++ b/Signal/Images.xcassets/media-composer-checkmark.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "media-composer-checkmark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Signal/Images.xcassets/media-composer-checkmark.imageset/media-composer-checkmark.pdf b/Signal/Images.xcassets/media-composer-checkmark.imageset/media-composer-checkmark.pdf new file mode 100644 index 0000000000..5e62edf61c Binary files /dev/null and b/Signal/Images.xcassets/media-composer-checkmark.imageset/media-composer-checkmark.pdf differ diff --git a/Signal/Images.xcassets/media-composer-close.imageset/Contents.json b/Signal/Images.xcassets/media-composer-close.imageset/Contents.json new file mode 100644 index 0000000000..b16c2030f5 --- /dev/null +++ b/Signal/Images.xcassets/media-composer-close.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "media_composer_close.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Signal/Images.xcassets/media-composer-close.imageset/media_composer_close.pdf b/Signal/Images.xcassets/media-composer-close.imageset/media_composer_close.pdf new file mode 100644 index 0000000000..122e40ab70 --- /dev/null +++ b/Signal/Images.xcassets/media-composer-close.imageset/media_composer_close.pdf @@ -0,0 +1,229 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 4.469971 4.469727 cm +0.000000 0.000000 0.000000 scn +15.060001 14.000058 m +14.000000 15.060059 l +7.530000 8.591059 l +1.060000 15.060059 l +0.000000 14.000058 l +6.469000 7.530058 l +0.000000 1.060059 l +1.060000 0.000057 l +7.530000 6.469059 l +14.000000 0.000057 l +15.060001 1.060059 l +8.591000 7.530058 l +15.060001 14.000058 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 4.469971 3.409180 cm +0.000000 0.000000 0.000000 scn +15.060001 15.060605 m +15.325187 14.795461 l +15.590311 15.060626 l +15.325167 15.325770 l +15.060001 15.060605 l +h +14.000000 16.120605 m +14.265165 16.385771 l +14.000020 16.650915 l +13.734856 16.385792 l +14.000000 16.120605 l +h +7.530000 9.651606 m +7.264856 9.386419 l +7.530000 9.121316 l +7.795145 9.386419 l +7.530000 9.651606 l +h +1.060000 16.120605 m +1.325145 16.385792 l +1.059980 16.650915 l +0.794835 16.385771 l +1.060000 16.120605 l +h +0.000000 15.060605 m +-0.265165 15.325770 l +-0.530310 15.060625 l +-0.265186 14.795461 l +0.000000 15.060605 l +h +6.469000 8.590605 m +6.734186 8.325460 l +6.999290 8.590605 l +6.734186 8.855749 l +6.469000 8.590605 l +h +0.000000 2.120605 m +-0.265186 2.385750 l +-0.530309 2.120585 l +-0.265165 1.855440 l +0.000000 2.120605 l +h +1.060000 1.060604 m +0.794835 0.795439 l +1.059980 0.530294 l +1.325145 0.795419 l +1.060000 1.060604 l +h +7.530000 7.529606 m +7.795145 7.794791 l +7.530000 8.059896 l +7.264855 7.794791 l +7.530000 7.529606 l +h +14.000000 1.060604 m +13.734856 0.795419 l +14.000020 0.530294 l +14.265165 0.795439 l +14.000000 1.060604 l +h +15.060001 2.120605 m +15.325167 1.855440 l +15.590311 2.120585 l +15.325187 2.385750 l +15.060001 2.120605 l +h +8.591000 8.590605 m +8.325814 8.855750 l +8.060710 8.590605 l +8.325814 8.325460 l +8.591000 8.590605 l +h +15.325167 15.325770 m +14.265165 16.385771 l +13.734835 15.855440 l +14.794836 14.795440 l +15.325167 15.325770 l +h +13.734856 16.385792 m +7.264856 9.916790 l +7.795145 9.386419 l +14.265144 15.855420 l +13.734856 16.385792 l +h +7.795145 9.916790 m +1.325145 16.385792 l +0.794856 15.855420 l +7.264856 9.386419 l +7.795145 9.916790 l +h +0.794835 16.385771 m +-0.265165 15.325770 l +0.265165 14.795440 l +1.325166 15.855440 l +0.794835 16.385771 l +h +-0.265186 14.795461 m +6.203815 8.325460 l +6.734186 8.855749 l +0.265186 15.325749 l +-0.265186 14.795461 l +h +6.203815 8.855749 m +-0.265186 2.385750 l +0.265186 1.855461 l +6.734186 8.325460 l +6.203815 8.855749 l +h +-0.265165 1.855440 m +0.794835 0.795439 l +1.325166 1.325769 l +0.265165 2.385771 l +-0.265165 1.855440 l +h +1.325145 0.795419 m +7.795145 7.264421 l +7.264855 7.794791 l +0.794856 1.325789 l +1.325145 0.795419 l +h +7.264855 7.264421 m +13.734856 0.795419 l +14.265144 1.325789 l +7.795145 7.794791 l +7.264855 7.264421 l +h +14.265165 0.795439 m +15.325167 1.855440 l +14.794836 2.385771 l +13.734835 1.325769 l +14.265165 0.795439 l +h +15.325187 2.385750 m +8.856185 8.855750 l +8.325814 8.325460 l +14.794816 1.855461 l +15.325187 2.385750 l +h +8.856185 8.325460 m +15.325187 14.795461 l +14.794816 15.325749 l +8.325814 8.855750 l +8.856185 8.325460 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 3034 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000003124 00000 n +0000003147 00000 n +0000003320 00000 n +0000003394 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +3453 +%%EOF \ No newline at end of file diff --git a/Signal/Images.xcassets/media-composer-create-album-outline-24.imageset/Contents.json b/Signal/Images.xcassets/media-composer-create-album-outline-24.imageset/Contents.json new file mode 100644 index 0000000000..4266319043 --- /dev/null +++ b/Signal/Images.xcassets/media-composer-create-album-outline-24.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "create-album-outline-24.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Signal/Images.xcassets/media-composer-create-album-outline-24.imageset/create-album-outline-24.pdf b/Signal/Images.xcassets/media-composer-create-album-outline-24.imageset/create-album-outline-24.pdf new file mode 100644 index 0000000000..146295452b --- /dev/null +++ b/Signal/Images.xcassets/media-composer-create-album-outline-24.imageset/create-album-outline-24.pdf @@ -0,0 +1,121 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 2.000000 2.000000 cm +0.000000 0.000000 0.000000 scn +16.584000 1.500000 m +16.322899 1.045122 15.946626 0.667013 15.493025 0.403700 c +15.039424 0.140387 14.524487 0.001152 14.000000 0.000000 c +4.500000 0.000000 l +3.306526 0.000000 2.161933 0.474106 1.318019 1.318020 c +0.474106 2.161934 0.000000 3.306526 0.000000 4.500000 c +0.000000 14.000000 l +0.001153 14.524487 0.140387 15.039425 0.403700 15.493025 c +0.667013 15.946627 1.045122 16.322899 1.500000 16.584000 c +1.500000 4.500000 l +1.500000 3.704351 1.816071 2.941288 2.378680 2.378679 c +2.941289 1.816071 3.704350 1.500000 4.500000 1.500000 c +16.584000 1.500000 l +h +17.000000 18.500000 m +6.000000 18.500000 l +5.602175 18.500000 5.220644 18.341965 4.939340 18.060659 c +4.658035 17.779356 4.500000 17.397825 4.500000 17.000000 c +4.500000 6.000000 l +4.500000 5.602176 4.658035 5.220645 4.939340 4.939341 c +5.220644 4.658036 5.602175 4.500000 6.000000 4.500000 c +17.000000 4.500000 l +17.397825 4.500000 17.779354 4.658036 18.060659 4.939341 c +18.341965 5.220645 18.500000 5.602176 18.500000 6.000000 c +18.500000 17.000000 l +18.500000 17.397825 18.341965 17.779356 18.060659 18.060659 c +17.779354 18.341965 17.397825 18.500000 17.000000 18.500000 c +h +17.000000 20.000000 m +17.795649 20.000000 18.558712 19.683929 19.121321 19.121321 c +19.683929 18.558712 20.000000 17.795650 20.000000 17.000000 c +20.000000 6.000000 l +20.000000 5.204350 19.683929 4.441288 19.121321 3.878679 c +18.558712 3.316071 17.795649 3.000000 17.000000 3.000000 c +6.000000 3.000000 l +5.204350 3.000000 4.441289 3.316071 3.878680 3.878679 c +3.316071 4.441288 3.000000 5.204350 3.000000 6.000000 c +3.000000 17.000000 l +3.000000 17.795650 3.316071 18.558712 3.878680 19.121321 c +4.441289 19.683929 5.204350 20.000000 6.000000 20.000000 c +17.000000 20.000000 l +h +15.500000 12.250000 m +12.250000 12.250000 l +12.250000 15.500000 l +10.750000 15.500000 l +10.750000 12.250000 l +7.500000 12.250000 l +7.500000 10.750000 l +10.750000 10.750000 l +10.750000 7.500000 l +12.250000 7.500000 l +12.250000 10.750000 l +15.500000 10.750000 l +15.500000 12.250000 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 2134 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000002224 00000 n +0000002247 00000 n +0000002420 00000 n +0000002494 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2553 +%%EOF \ No newline at end of file diff --git a/Signal/Images.xcassets/media-composer-create-album-solid-24.imageset/Contents.json b/Signal/Images.xcassets/media-composer-create-album-solid-24.imageset/Contents.json new file mode 100644 index 0000000000..5dfa74c324 --- /dev/null +++ b/Signal/Images.xcassets/media-composer-create-album-solid-24.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "create-album-solid-24.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Signal/Images.xcassets/media-composer-create-album-solid-24.imageset/create-album-solid-24.pdf b/Signal/Images.xcassets/media-composer-create-album-solid-24.imageset/create-album-solid-24.pdf new file mode 100644 index 0000000000..8484574055 Binary files /dev/null and b/Signal/Images.xcassets/media-composer-create-album-solid-24.imageset/create-album-solid-24.pdf differ diff --git a/Signal/Images.xcassets/media-composer-flash-auto-24.imageset/Contents.json b/Signal/Images.xcassets/media-composer-flash-auto-24.imageset/Contents.json new file mode 100644 index 0000000000..05a001a88e --- /dev/null +++ b/Signal/Images.xcassets/media-composer-flash-auto-24.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "media-composer-flash-auto-24.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Signal/Images.xcassets/media-composer-flash-auto-24.imageset/media-composer-flash-auto-24.pdf b/Signal/Images.xcassets/media-composer-flash-auto-24.imageset/media-composer-flash-auto-24.pdf new file mode 100644 index 0000000000..784656ed6d --- /dev/null +++ b/Signal/Images.xcassets/media-composer-flash-auto-24.imageset/media-composer-flash-auto-24.pdf @@ -0,0 +1,125 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 3.148315 0.646362 cm +0.000000 0.000000 0.000000 scn +10.394505 22.621460 m +10.186519 22.621460 9.971912 22.518707 9.860236 22.307106 c +0.186246 9.979193 l +0.027322 9.816003 -0.018069 9.583500 0.006074 9.378285 c +0.030281 9.172525 0.128247 8.961831 0.296594 8.849599 c +0.303070 8.845283 l +0.312180 8.840727 l +0.396315 8.798658 0.484727 8.754453 0.563120 8.720855 c +0.602809 8.703845 0.642242 8.688539 0.679090 8.677324 c +0.714395 8.666579 0.754920 8.657176 0.794503 8.657176 c +8.269956 8.657176 l +6.672553 0.838310 l +6.566589 0.407591 6.894166 0.102909 7.183547 0.006449 c +7.202790 0.000034 l +7.308789 0.000034 l +7.515537 0.000034 7.718171 0.100496 7.825749 0.208075 c +7.831357 0.213682 l +17.517363 12.728341 l +17.676031 12.891515 17.721346 13.123846 17.697220 13.328923 c +17.673504 13.530506 17.578995 13.736824 17.416866 13.850653 c +17.307270 13.954901 17.109980 14.050033 16.908789 14.050033 c +9.433671 14.050033 l +11.030709 21.783056 l +11.136769 22.213842 10.809155 22.518576 10.519747 22.615046 c +10.515336 22.601814 l +10.495173 22.615263 10.479262 22.621460 10.479256 22.621460 c +10.394505 22.621460 l +h +8.522223 18.133879 m +8.967959 18.822742 l +8.731995 18.077103 l +7.495820 12.514318 l +15.035454 12.514318 l +15.499903 12.607208 l +15.100375 12.207681 l +9.094956 4.401608 l +8.752861 3.854254 l +8.971349 4.544376 l +9.830936 8.498480 l +10.207474 10.192890 l +2.755587 10.192890 l +2.144394 10.091025 l +2.600486 10.496440 l +8.522223 18.133879 l +h +17.606695 7.792892 m +15.095354 7.792892 l +13.494057 0.942892 l +15.034959 0.942892 l +15.377815 2.314320 l +17.411192 2.314320 l +17.754049 0.942892 l +19.296955 0.942892 l +17.606695 7.792892 l +h +15.693610 3.764320 m +16.394505 6.879395 l +17.095398 3.764320 l +15.693610 3.764320 l +h +f* +n +Q + +endstream +endobj + +3 0 obj + 1797 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001887 00000 n +0000001910 00000 n +0000002083 00000 n +0000002157 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2216 +%%EOF \ No newline at end of file diff --git a/Signal/Images.xcassets/media-composer-flash-filled-24.imageset/Contents.json b/Signal/Images.xcassets/media-composer-flash-filled-24.imageset/Contents.json new file mode 100644 index 0000000000..c63870d317 --- /dev/null +++ b/Signal/Images.xcassets/media-composer-flash-filled-24.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "media-composer-flash-filled-24.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Signal/Images.xcassets/media-composer-flash-filled-24.imageset/media-composer-flash-filled-24.pdf b/Signal/Images.xcassets/media-composer-flash-filled-24.imageset/media-composer-flash-filled-24.pdf new file mode 100644 index 0000000000..1175799051 --- /dev/null +++ b/Signal/Images.xcassets/media-composer-flash-filled-24.imageset/media-composer-flash-filled-24.pdf @@ -0,0 +1,93 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 3.148315 0.646362 cm +0.000000 0.000000 0.000000 scn +10.394504 22.621460 m +10.186520 22.621460 9.971915 22.518707 9.860238 22.307110 c +0.186246 9.979193 l +0.027322 9.816003 -0.018069 9.583500 0.006074 9.378285 c +0.030281 9.172525 0.128247 8.961831 0.296594 8.849599 c +0.303070 8.845283 l +0.312179 8.840727 l +0.396315 8.798658 0.484726 8.754453 0.563120 8.720855 c +0.602809 8.703845 0.642242 8.688539 0.679090 8.677324 c +0.714395 8.666579 0.754920 8.657176 0.794503 8.657176 c +8.269956 8.657176 l +6.672552 0.838310 l +6.566589 0.407591 6.894166 0.102909 7.183546 0.006449 c +7.202790 0.000034 l +7.308788 0.000034 l +7.515537 0.000034 7.718170 0.100496 7.825748 0.208075 c +7.831357 0.213682 l +17.517353 12.728332 l +17.676027 12.891506 17.721346 13.123842 17.697218 13.328923 c +17.673502 13.530502 17.578995 13.736816 17.416872 13.850647 c +17.307278 13.954897 17.109980 14.050033 16.908789 14.050033 c +9.433671 14.050033 l +11.030708 21.783056 l +11.136768 22.213842 10.809155 22.518576 10.519746 22.615046 c +10.515335 22.601814 l +10.495173 22.615263 10.479261 22.621460 10.479255 22.621460 c +10.394504 22.621460 l +h +f* +n +Q + +endstream +endobj + +3 0 obj + 1182 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001272 00000 n +0000001295 00000 n +0000001468 00000 n +0000001542 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1601 +%%EOF \ No newline at end of file diff --git a/Signal/Images.xcassets/media-composer-flash-outline-24.imageset/Contents.json b/Signal/Images.xcassets/media-composer-flash-outline-24.imageset/Contents.json new file mode 100644 index 0000000000..93c457fea5 --- /dev/null +++ b/Signal/Images.xcassets/media-composer-flash-outline-24.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "media-composer-flash-outline-24.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Signal/Images.xcassets/media-composer-flash-outline-24.imageset/media-composer-flash-outline-24.pdf b/Signal/Images.xcassets/media-composer-flash-outline-24.imageset/media-composer-flash-outline-24.pdf new file mode 100644 index 0000000000..38181245b3 --- /dev/null +++ b/Signal/Images.xcassets/media-composer-flash-outline-24.imageset/media-composer-flash-outline-24.pdf @@ -0,0 +1,110 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 3.148315 0.646362 cm +0.000000 0.000000 0.000000 scn +10.394504 22.621460 m +10.186520 22.621460 9.971915 22.518707 9.860238 22.307110 c +0.186246 9.979193 l +0.027322 9.816003 -0.018069 9.583500 0.006074 9.378285 c +0.030281 9.172525 0.128247 8.961831 0.296594 8.849599 c +0.303070 8.845283 l +0.312179 8.840727 l +0.396315 8.798658 0.484726 8.754453 0.563120 8.720855 c +0.602809 8.703845 0.642242 8.688539 0.679090 8.677324 c +0.714395 8.666579 0.754920 8.657176 0.794503 8.657176 c +8.269956 8.657176 l +6.672552 0.838310 l +6.566589 0.407591 6.894166 0.102909 7.183546 0.006449 c +7.202790 0.000034 l +7.308788 0.000034 l +7.515537 0.000034 7.718170 0.100496 7.825748 0.208075 c +7.831357 0.213682 l +17.517353 12.728332 l +17.676027 12.891506 17.721346 13.123842 17.697218 13.328923 c +17.673502 13.530502 17.578995 13.736816 17.416872 13.850647 c +17.307278 13.954897 17.109980 14.050033 16.908789 14.050033 c +9.433671 14.050033 l +11.030708 21.783056 l +11.136768 22.213842 10.809155 22.518576 10.519746 22.615046 c +10.515335 22.601814 l +10.495173 22.615263 10.479261 22.621460 10.479255 22.621460 c +10.394504 22.621460 l +h +8.522223 18.133879 m +8.967960 18.822746 l +8.731994 18.077103 l +7.495819 12.514318 l +15.035453 12.514318 l +15.499902 12.607208 l +15.100374 12.207681 l +9.094956 4.401608 l +8.752862 3.854258 l +8.971348 4.544376 l +9.830936 8.498480 l +10.207473 10.192890 l +2.755587 10.192890 l +2.144394 10.091025 l +2.600486 10.496440 l +8.522223 18.133879 l +h +f* +n +Q + +endstream +endobj + +3 0 obj + 1520 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001610 00000 n +0000001633 00000 n +0000001806 00000 n +0000001880 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1939 +%%EOF \ No newline at end of file diff --git a/Signal/Images.xcassets/media-composer-lock-outline-24.imageset/Contents.json b/Signal/Images.xcassets/media-composer-lock-outline-24.imageset/Contents.json new file mode 100644 index 0000000000..c0b8216e57 --- /dev/null +++ b/Signal/Images.xcassets/media-composer-lock-outline-24.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "media-composer-lock-outline-24.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Signal/Images.xcassets/media-composer-lock-outline-24.imageset/media-composer-lock-outline-24.pdf b/Signal/Images.xcassets/media-composer-lock-outline-24.imageset/media-composer-lock-outline-24.pdf new file mode 100644 index 0000000000..ff0cfb7f23 --- /dev/null +++ b/Signal/Images.xcassets/media-composer-lock-outline-24.imageset/media-composer-lock-outline-24.pdf @@ -0,0 +1,121 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 4.000000 2.000000 cm +0.105882 0.105882 0.105882 scn +13.000000 13.000000 m +13.000000 16.000000 l +13.000000 17.326082 12.473216 18.597853 11.535534 19.535534 c +10.597852 20.473215 9.326082 21.000000 8.000000 21.000000 c +6.673918 21.000000 5.402148 20.473215 4.464466 19.535534 c +3.526784 18.597853 3.000000 17.326082 3.000000 16.000000 c +3.000000 13.000000 l +2.204350 13.000000 1.441289 12.683929 0.878680 12.121321 c +0.316071 11.558712 0.000000 10.795650 0.000000 10.000000 c +0.000000 3.000000 l +0.000000 2.204351 0.316071 1.441288 0.878680 0.878679 c +1.441289 0.316071 2.204350 0.000000 3.000000 0.000000 c +13.000000 0.000000 l +13.795650 0.000000 14.558712 0.316071 15.121321 0.878679 c +15.683929 1.441288 16.000000 2.204351 16.000000 3.000000 c +16.000000 10.000000 l +16.000000 10.795650 15.683929 11.558712 15.121321 12.121321 c +14.558712 12.683929 13.795650 13.000000 13.000000 13.000000 c +h +4.500000 16.000000 m +4.500000 16.928257 4.868749 17.818497 5.525126 18.474874 c +6.181504 19.131250 7.071742 19.500000 8.000000 19.500000 c +8.928258 19.500000 9.818497 19.131250 10.474874 18.474874 c +11.131250 17.818497 11.500000 16.928257 11.500000 16.000000 c +11.500000 13.000000 l +4.500000 13.000000 l +4.500000 16.000000 l +h +14.500000 3.000000 m +14.500000 2.602175 14.341964 2.220646 14.060659 1.939341 c +13.779355 1.658035 13.397824 1.500000 13.000000 1.500000 c +3.000000 1.500000 l +2.602175 1.500000 2.220644 1.658035 1.939340 1.939341 c +1.658035 2.220646 1.500000 2.602175 1.500000 3.000000 c +1.500000 10.000000 l +1.500000 10.397824 1.658035 10.779356 1.939340 11.060660 c +2.220644 11.341965 2.602175 11.500000 3.000000 11.500000 c +13.000000 11.500000 l +13.397824 11.500000 13.779355 11.341965 14.060659 11.060660 c +14.341964 10.779356 14.500000 10.397824 14.500000 10.000000 c +14.500000 3.000000 l +h +8.750000 6.208000 m +8.750000 4.000000 l +7.250000 4.000000 l +7.250000 6.208000 l +6.964037 6.373101 6.740543 6.627947 6.614181 6.933013 c +6.487818 7.238079 6.465649 7.576317 6.551111 7.895267 c +6.636574 8.214216 6.824892 8.496055 7.086858 8.697068 c +7.348824 8.898082 7.669799 9.007038 8.000000 9.007038 c +8.330201 9.007038 8.651175 8.898082 8.913142 8.697068 c +9.175109 8.496055 9.363426 8.214216 9.448889 7.895267 c +9.534351 7.576317 9.512182 7.238079 9.385819 6.933013 c +9.259457 6.627947 9.035963 6.373101 8.750000 6.208000 c +8.750000 6.208000 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 2423 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000002513 00000 n +0000002536 00000 n +0000002709 00000 n +0000002783 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2842 +%%EOF \ No newline at end of file diff --git a/Signal/Images.xcassets/media-composer-navbar-chevron.imageset/Contents.json b/Signal/Images.xcassets/media-composer-navbar-chevron.imageset/Contents.json new file mode 100644 index 0000000000..6f0da23901 --- /dev/null +++ b/Signal/Images.xcassets/media-composer-navbar-chevron.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "media-composer-navbar-chevron.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Signal/Images.xcassets/media-composer-navbar-chevron.imageset/media-composer-navbar-chevron.pdf b/Signal/Images.xcassets/media-composer-navbar-chevron.imageset/media-composer-navbar-chevron.pdf new file mode 100644 index 0000000000..0baa5fd89a --- /dev/null +++ b/Signal/Images.xcassets/media-composer-navbar-chevron.imageset/media-composer-navbar-chevron.pdf @@ -0,0 +1,73 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +0.000000 -1.000000 1.000000 0.000000 4.995239 8.406494 cm +0.000000 0.000000 0.000000 scn +5.508584 1.675859 m +0.000000 6.963662 l +1.385004 8.406494 l +8.406458 1.666456 l +1.375829 -4.995262 l +0.000217 -3.543472 l +5.508584 1.675859 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 269 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 13.401733 8.406494 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000000359 00000 n +0000000381 00000 n +0000000553 00000 n +0000000627 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +686 +%%EOF \ No newline at end of file diff --git a/Signal/Images.xcassets/media-composer-switch-camera-24.imageset/Contents.json b/Signal/Images.xcassets/media-composer-switch-camera-24.imageset/Contents.json new file mode 100644 index 0000000000..06a95557b1 --- /dev/null +++ b/Signal/Images.xcassets/media-composer-switch-camera-24.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "switch-camera-24.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Signal/Images.xcassets/media-composer-switch-camera-24.imageset/switch-camera-24.pdf b/Signal/Images.xcassets/media-composer-switch-camera-24.imageset/switch-camera-24.pdf new file mode 100644 index 0000000000..b7c885ec3a --- /dev/null +++ b/Signal/Images.xcassets/media-composer-switch-camera-24.imageset/switch-camera-24.pdf @@ -0,0 +1,108 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.209961 2.659668 cm +0.000000 0.000000 0.000000 scn +18.150002 15.700432 m +17.090000 14.640432 l +16.396545 15.340194 15.570852 15.895062 14.660963 16.272751 c +13.751074 16.650442 12.775159 16.843410 11.790000 16.840431 c +9.800877 16.840431 7.893222 16.050255 6.486699 14.643732 c +5.080176 13.237209 4.290000 11.329556 4.290000 9.340431 c +4.290000 8.640431 l +5.000000 9.860432 l +6.000000 10.860432 l +7.070000 9.790431 l +3.540000 6.280431 l +0.000000 9.810431 l +1.070000 10.880432 l +2.070000 9.880431 l +2.790000 8.640431 l +2.790000 9.340431 l +2.791565 11.119312 3.320253 12.857834 4.309292 14.336420 c +5.298331 15.815008 6.703359 16.967339 8.346918 17.647875 c +9.990478 18.328411 11.798848 18.506628 13.543634 18.160015 c +15.288421 17.813402 16.891361 16.957508 18.150002 15.700432 c +18.150002 15.700432 l +h +20.040001 12.400433 m +16.500000 8.870431 l +17.570002 7.800431 l +18.570002 8.800431 l +19.280001 10.020432 l +19.280001 9.340431 l +19.280003 7.353039 18.491199 5.446898 17.086838 4.040663 c +15.682477 2.634427 13.777390 1.843081 11.790000 1.840431 c +10.804841 1.837452 9.828926 2.030422 8.919037 2.408112 c +8.009148 2.785801 7.183455 3.340670 6.490000 4.040432 c +5.430000 2.980431 l +6.688641 1.723354 8.291579 0.867460 10.036366 0.520847 c +11.781152 0.174234 13.589522 0.352451 15.233082 1.032988 c +16.876642 1.713524 18.281670 2.865855 19.270708 4.344442 c +20.259747 5.823030 20.788437 7.561551 20.790001 9.340431 c +20.790001 10.040431 l +21.500000 8.820431 l +22.500000 7.820431 l +23.570002 8.890431 l +20.040001 12.400433 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 1599 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001689 00000 n +0000001712 00000 n +0000001885 00000 n +0000001959 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2018 +%%EOF \ No newline at end of file diff --git a/Signal/Images.xcassets/navbar_disclosure_down.imageset/Contents.json b/Signal/Images.xcassets/navbar_disclosure_down.imageset/Contents.json deleted file mode 100644 index 3cfcd92ea0..0000000000 --- a/Signal/Images.xcassets/navbar_disclosure_down.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "navbar_disclosure_down_small@1x.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "navbar_disclosure_down_small@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "navbar_disclosure_down_small@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Signal/Images.xcassets/navbar_disclosure_down.imageset/navbar_disclosure_down_small@1x.png b/Signal/Images.xcassets/navbar_disclosure_down.imageset/navbar_disclosure_down_small@1x.png deleted file mode 100644 index fe04a6e29f..0000000000 Binary files a/Signal/Images.xcassets/navbar_disclosure_down.imageset/navbar_disclosure_down_small@1x.png and /dev/null differ diff --git a/Signal/Images.xcassets/navbar_disclosure_down.imageset/navbar_disclosure_down_small@2x.png b/Signal/Images.xcassets/navbar_disclosure_down.imageset/navbar_disclosure_down_small@2x.png deleted file mode 100644 index 9e8d593127..0000000000 Binary files a/Signal/Images.xcassets/navbar_disclosure_down.imageset/navbar_disclosure_down_small@2x.png and /dev/null differ diff --git a/Signal/Images.xcassets/navbar_disclosure_down.imageset/navbar_disclosure_down_small@3x.png b/Signal/Images.xcassets/navbar_disclosure_down.imageset/navbar_disclosure_down_small@3x.png deleted file mode 100644 index a8422b949f..0000000000 Binary files a/Signal/Images.xcassets/navbar_disclosure_down.imageset/navbar_disclosure_down_small@3x.png and /dev/null differ diff --git a/Signal/src/ViewControllers/Photos/ImagePickerController.swift b/Signal/src/ViewControllers/Photos/ImagePickerController.swift index f3becd21ee..1b5a201af1 100644 --- a/Signal/src/ViewControllers/Photos/ImagePickerController.swift +++ b/Signal/src/ViewControllers/Photos/ImagePickerController.swift @@ -1,34 +1,42 @@ // -// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// Copyright (c) 2022 Open Whisper Systems. All rights reserved. // import Foundation import Photos +import UIKit protocol ImagePickerGridControllerDelegate: AnyObject { - func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController) + func imagePickerDidRequestSendMedia(_ imagePicker: ImagePickerGridController) func imagePickerDidCancel(_ imagePicker: ImagePickerGridController) - func imagePicker(_ imagePicker: ImagePickerGridController, isAssetSelected asset: PHAsset) -> Bool func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPromise: Promise) func imagePicker(_ imagePicker: ImagePickerGridController, didDeselectAsset asset: PHAsset) - var isInBatchSelectMode: Bool { get } - func imagePickerCanSelectMoreItems(_ imagePicker: ImagePickerGridController) -> Bool func imagePickerDidTryToSelectTooMany(_ imagePicker: ImagePickerGridController) } +protocol ImagePickerGridControllerDataSource: AnyObject { + func imagePicker(_ imagePicker: ImagePickerGridController, isAssetSelected asset: PHAsset) -> Bool + func imagePickerCanSelectMoreItems(_ imagePicker: ImagePickerGridController) -> Bool + var numberOfMediaItems: Int { get } +} + class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegate, PhotoCollectionPickerDelegate { weak var delegate: ImagePickerGridControllerDelegate? + weak var dataSource: ImagePickerGridControllerDataSource? private let library: PhotoLibrary = PhotoLibrary() private var photoCollection: PhotoCollection private var photoCollectionContents: PhotoCollectionContents private let photoMediaSize = PhotoMediaSize() - var collectionViewFlowLayout: UICollectionViewFlowLayout - var titleView: TitleView! + private var collectionViewFlowLayout: UICollectionViewFlowLayout + private var titleView: TitleView! + + private var bottomBar: UIView! + private var doneButton: MediaDoneButton! init() { collectionViewFlowLayout = type(of: self).buildLayout() @@ -52,48 +60,49 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat return true } + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = Theme.darkThemeBackgroundColor + library.add(delegate: self) guard let collectionView = collectionView else { owsFailDebug("collectionView was unexpectedly nil") return } + collectionView.allowsMultipleSelection = true + collectionView.backgroundColor = Theme.darkThemeBackgroundColor collectionView.register(PhotoGridViewCell.self, forCellWithReuseIdentifier: PhotoGridViewCell.reuseIdentifier) - // ensure images at the end of the list can be scrolled above the bottom buttons - let bottomButtonInset = -1 * SendMediaNavigationController.bottomButtonsCenterOffset + SendMediaNavigationController.bottomButtonWidth / 2 - collectionView.contentInset.bottom = bottomButtonInset + 8 - view.backgroundColor = .ows_gray95 - - // The PhotoCaptureVC needs a shadow behind it's cancel button, so we use a custom icon. - // This VC has a visible navbar so doesn't need the shadow, but because the user can - // quickly toggle between the Capture and the Picker VC's, we use the same custom "X" - // icon here rather than the system "stop" icon so that the spacing matches exactly. - // Otherwise there's a noticable shift in the icon placement. - if UIDevice.current.isIPad { - let cancelButton = OWSButton.shadowedCancelButton { [weak self] in - self?.didPressCancel() - } - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: cancelButton) - } else { - let cancelImage = UIImage(imageLiteralResourceName: "ic_x_with_shadow") - let cancelButton = UIBarButtonItem(image: cancelImage, style: .plain, target: self, action: #selector(didPressCancel)) - - cancelButton.tintColor = .ows_gray05 - navigationItem.leftBarButtonItem = cancelButton - } + let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(didPressCancel)) + cancelButton.tintColor = .ows_gray05 + navigationItem.leftBarButtonItem = cancelButton let titleView = TitleView() titleView.delegate = self + titleView.tintColor = .ows_gray05 titleView.text = photoCollection.localizedTitle() - navigationItem.titleView = titleView self.titleView = titleView - collectionView.backgroundColor = .ows_gray95 + bottomBar = UIView() + view.addSubview(bottomBar) + bottomBar.preservesSuperviewLayoutMargins = true + bottomBar.autoPinBottomToSuperviewMargin(withInset: UIDevice.current.hasIPhoneXNotch ? 0 : 8) + bottomBar.autoPinHorizontalEdges(toEdgesOf: view) + + doneButton = MediaDoneButton() + doneButton.userInterfaceStyleOverride = .light + doneButton.addTarget(self, action: #selector(didTapDoneButton), for: .touchUpInside) + bottomBar.addSubview(doneButton) + doneButton.autoPinTopToSuperviewMargin() + doneButton.autoPinBottomToSuperviewMargin() + doneButton.autoPinTrailingToSuperviewMargin() let selectionPanGesture = DirectionalPanGestureRecognizer(direction: [.horizontal], target: self, action: #selector(didPanSelection)) selectionPanGesture.delegate = self @@ -101,31 +110,28 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat collectionView.addGestureRecognizer(selectionPanGesture) } - var selectionPanGesture: UIPanGestureRecognizer? - enum BatchSelectionGestureMode { + private var selectionPanGesture: UIPanGestureRecognizer? + private enum BatchSelectionGestureMode { case select, deselect } - var selectionPanGestureMode: BatchSelectionGestureMode = .select + private var selectionPanGestureMode: BatchSelectionGestureMode = .select @objc - func didPanSelection(_ selectionPanGesture: UIPanGestureRecognizer) { + private func didPanSelection(_ selectionPanGesture: UIPanGestureRecognizer) { guard let collectionView = collectionView else { owsFailDebug("collectionView was unexpectedly nil") return } - guard let delegate = delegate else { - owsFailDebug("delegate was unexpectedly nil") - return - } - - guard delegate.isInBatchSelectMode else { + guard let dataSource = dataSource else { + owsFailDebug("dataSource was unexpectedly nil") return } switch selectionPanGesture.state { case .possible: break + case .began: collectionView.isUserInteractionEnabled = false collectionView.isScrollEnabled = false @@ -135,11 +141,12 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat return } let asset = photoCollectionContents.asset(at: indexPath.item) - if delegate.imagePicker(self, isAssetSelected: asset) { + if dataSource.imagePicker(self, isAssetSelected: asset) { selectionPanGestureMode = .deselect } else { selectionPanGestureMode = .select } + case .changed: let velocity = selectionPanGesture.velocity(in: view) @@ -160,27 +167,24 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat return } tryToToggleBatchSelect(at: indexPath) + case .cancelled, .ended, .failed: collectionView.isUserInteractionEnabled = true collectionView.isScrollEnabled = true + @unknown default: owsFailDebug("unexpected selectionPanGesture.state: \(selectionPanGesture.state)") } } - func tryToToggleBatchSelect(at indexPath: IndexPath) { + private func tryToToggleBatchSelect(at indexPath: IndexPath) { guard let collectionView = collectionView else { owsFailDebug("collectionView was unexpectedly nil") return } - guard let delegate = delegate else { - owsFailDebug("delegate was unexpectedly nil") - return - } - - guard delegate.isInBatchSelectMode else { - owsFailDebug("isInBatchSelectMode was unexpectedly false") + guard let delegate = delegate, let dataSource = dataSource else { + owsFailDebug("delegate or dataSource was unexpectedly nil") return } @@ -191,7 +195,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat return } - guard delegate.imagePickerCanSelectMoreItems(self) else { + guard dataSource.imagePickerCanSelectMoreItems(self) else { delegate.imagePickerDidTryToSelectTooMany(self) return } @@ -214,7 +218,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat updateLayout() } - var hasEverAppeared: Bool = false + private var hasEverAppeared: Bool = false override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -227,6 +231,8 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat if !hasEverAppeared { scrollToBottom(animated: false) } + + updateDoneButtonAppearance() } override func viewSafeAreaInsetsDidChange() { @@ -257,14 +263,21 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat } } - // MARK: + private func updateDoneButtonAppearance() { + guard let dataSource = dataSource else { return } - var lastPageYOffset: CGFloat { + doneButton.badgeNumber = dataSource.numberOfMediaItems + doneButton.isHidden = doneButton.badgeNumber == 0 + } + + // MARK: - Scrolling + + private var lastPageYOffset: CGFloat { let yOffset = collectionView.contentSize.height - collectionView.frame.height + collectionView.contentInset.bottom + view.safeAreaInsets.bottom return yOffset } - func scrollToBottom(animated: Bool) { + private func scrollToBottom(animated: Bool) { self.view.layoutIfNeeded() guard let collectionView = collectionView else { @@ -296,7 +309,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat } } - public func reloadData() { + private func reloadData() { guard let collectionView = collectionView else { owsFailDebug("Missing collectionView.") return @@ -309,13 +322,18 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat // MARK: - Actions @objc - func didPressCancel() { + private func didPressCancel() { self.delegate?.imagePickerDidCancel(self) } + @objc + private func didTapDoneButton() { + delegate?.imagePickerDidRequestSendMedia(self) + } + // MARK: - Layout - static let kInterItemSpacing: CGFloat = 2 + private static let kInterItemSpacing: CGFloat = 2 private class func buildLayout() -> UICollectionViewFlowLayout { let layout = UICollectionViewFlowLayout() @@ -327,7 +345,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat return layout } - func updateLayout() { + private func updateLayout() { let containerWidth = self.view.safeAreaLayoutGuide.layoutFrame.size.width let minItemWidth: CGFloat = 100 @@ -351,7 +369,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat // MARK: - Batch Selection - func isSelected(indexPath: IndexPath) -> Bool { + private func isSelected(indexPath: IndexPath) -> Bool { guard let selectedIndexPaths = collectionView.indexPathsForSelectedItems else { return false } @@ -359,24 +377,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat return selectedIndexPaths.contains(indexPath) } - func batchSelectModeDidChange() { - applyBatchSelectMode() - } - - func applyBatchSelectMode() { - guard let delegate = delegate else { - return - } - - guard let collectionView = collectionView else { - owsFailDebug("collectionView was unexpectedly nil") - return - } - - collectionView.allowsMultipleSelection = delegate.isInBatchSelectMode - updateVisibleCells() - } - // MARK: - PhotoLibraryDelegate func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) { @@ -386,16 +386,14 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat // MARK: - PhotoCollectionPicker Presentation - var isShowingCollectionPickerController: Bool = false + private var isShowingCollectionPickerController: Bool = false - lazy var collectionPickerController: PhotoCollectionPickerController = { + private lazy var collectionPickerController: PhotoCollectionPickerController = { return PhotoCollectionPickerController(library: library, collectionDelegate: self) }() - func showCollectionPicker() { - Logger.debug("") - + private func showCollectionPicker() { guard let collectionPickerView = collectionPickerController.view else { owsFailDebug("collectionView was unexpectedly nil") return @@ -419,9 +417,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat } } - func hideCollectionPicker() { - Logger.debug("") - + private func hideCollectionPicker() { assert(isShowingCollectionPickerController) isShowingCollectionPickerController = false @@ -462,14 +458,14 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat // MARK: - UICollectionView override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { - guard let delegate = delegate else { return false } + guard let delegate = delegate, let dataSource = dataSource else { return false } - if delegate.imagePickerCanSelectMoreItems(self) { + if dataSource.imagePickerCanSelectMoreItems(self) { return true - } else { - delegate.imagePickerDidTryToSelectTooMany(self) - return false } + + delegate.imagePickerDidTryToSelectTooMany(self) + return false } override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { @@ -481,16 +477,10 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat let asset: PHAsset = photoCollectionContents.asset(at: indexPath.item) let attachmentPromise: Promise = photoCollectionContents.outgoingAttachment(for: asset) delegate.imagePicker(self, didSelectAsset: asset, attachmentPromise: attachmentPromise) - - if !delegate.isInBatchSelectMode { - // Don't show "selected" badge unless we're in batch mode - collectionView.deselectItem(at: indexPath, animated: false) - delegate.imagePickerDidCompleteSelection(self) - } + updateDoneButtonAppearance() } - public override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { - Logger.debug("") + override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { guard let delegate = delegate else { owsFailDebug("delegate was unexpectedly nil") return @@ -498,6 +488,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat let asset = photoCollectionContents.asset(at: indexPath.item) delegate.imagePicker(self, didDeselectAsset: asset) + updateDoneButtonAppearance() } override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { @@ -516,13 +507,13 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat } override func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { - guard let delegate = delegate else { return } + guard let dataSource = dataSource else { return } guard let photoGridViewCell = cell as? PhotoGridViewCell else { owsFailDebug("unexpected cell: \(cell)") return } let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize) - let isSelected = delegate.imagePicker(self, isAssetSelected: assetItem.asset) + let isSelected = dataSource.imagePicker(self, isAssetSelected: assetItem.asset) if isSelected { collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) } else { @@ -532,8 +523,9 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat photoGridViewCell.allowsMultipleSelection = collectionView.allowsMultipleSelection } - func updateVisibleCells() { - guard let delegate = delegate else { return } + private func updateVisibleCells() { + guard let dataSource = dataSource else { return } + for cell in collectionView.visibleCells { guard let photoGridViewCell = cell as? PhotoGridViewCell else { owsFailDebug("unexpected cell: \(cell)") @@ -545,13 +537,14 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat continue } - photoGridViewCell.isSelected = delegate.imagePicker(self, isAssetSelected: assetItem.asset) + photoGridViewCell.isSelected = dataSource.imagePicker(self, isAssetSelected: assetItem.asset) photoGridViewCell.allowsMultipleSelection = collectionView.allowsMultipleSelection } } } extension ImagePickerGridController: UIGestureRecognizerDelegate { + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { guard gestureRecognizer == selectionPanGesture else { return true @@ -561,52 +554,75 @@ extension ImagePickerGridController: UIGestureRecognizerDelegate { } } -protocol TitleViewDelegate: AnyObject { +private protocol TitleViewDelegate: AnyObject { func titleViewWasTapped(_ titleView: TitleView) } -class TitleView: UIView { - - // MARK: - Private +private class TitleView: UIView { private let label = UILabel() private let iconView = UIImageView() private let stackView: UIStackView + // Returns same font as UIBarButtonItem uses. + private func titleLabelFont() -> UIFont { + let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) + return UIFont(descriptor: fontDescriptor, size: fontDescriptor.pointSize.clamp(17, 21)) + } + // MARK: - Initializers override init(frame: CGRect) { - let stackView = UIStackView(arrangedSubviews: [label, iconView]) + stackView = UIStackView(arrangedSubviews: [label, iconView]) + + super.init(frame: frame) + stackView.axis = .horizontal stackView.alignment = .center stackView.spacing = 5 stackView.isUserInteractionEnabled = true - - self.stackView = stackView - - super.init(frame: frame) - addSubview(stackView) stackView.autoPinEdgesToSuperviewEdges() - label.textColor = .ows_gray05 - label.font = UIFont.ows_dynamicTypeBody.ows_semibold + label.textColor = tintColor + label.font = titleLabelFont() - iconView.tintColor = .ows_gray05 - iconView.image = UIImage(named: "navbar_disclosure_down")?.withRenderingMode(.alwaysTemplate) + iconView.tintColor = tintColor + if #available(iOS 13, *) { + iconView.image = UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(font: label.font)) + } else { + iconView.image = UIImage(named: "media-composer-navbar-chevron") + } addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(titleTapped))) } - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") + @available(*, unavailable, message: "Use init(frame:) instead") + required init?(coder: NSCoder) { + notImplemented() + } + + override func tintColorDidChange() { + super.tintColorDidChange() + label.textColor = tintColor + iconView.tintColor = tintColor + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory { + label.font = titleLabelFont() + if #available(iOS 13, *) { + iconView.image = UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(font: label.font)) + } + } } // MARK: - Public weak var delegate: TitleViewDelegate? - public var text: String? { + var text: String? { get { return label.text } @@ -615,11 +631,11 @@ class TitleView: UIView { } } - public enum TitleViewRotationDirection { + enum TitleViewRotationDirection { case up, down } - public func rotateIcon(_ direction: TitleViewRotationDirection) { + func rotateIcon(_ direction: TitleViewRotationDirection) { switch direction { case .up: // *slightly* more than `pi` to ensure the chevron animates counter-clockwise @@ -639,7 +655,7 @@ class TitleView: UIView { } extension ImagePickerGridController: TitleViewDelegate { - func titleViewWasTapped(_ titleView: TitleView) { + fileprivate func titleViewWasTapped(_ titleView: TitleView) { if isShowingCollectionPickerController { hideCollectionPicker() } else { diff --git a/Signal/src/ViewControllers/Photos/MediaControls.swift b/Signal/src/ViewControllers/Photos/MediaControls.swift new file mode 100644 index 0000000000..3248046e0f --- /dev/null +++ b/Signal/src/ViewControllers/Photos/MediaControls.swift @@ -0,0 +1,801 @@ +// +// Copyright (c) 2022 Open Whisper Systems. All rights reserved. +// + +import UIKit +import SignalUI + +protocol CameraCaptureControlDelegate: AnyObject { + // MARK: Photo + func cameraCaptureControlDidRequestCapturePhoto(_ control: CameraCaptureControl) + + // MARK: Video + func cameraCaptureControlDidRequestStartVideoRecording(_ control: CameraCaptureControl) + func cameraCaptureControlDidRequestFinishVideoRecording(_ control: CameraCaptureControl) + func cameraCaptureControlDidRequestCancelVideoRecording(_ control: CameraCaptureControl) + + // MARK: Zoom + var zoomScaleReferenceDistance: CGFloat? { get } + func cameraCaptureControl(_ control: CameraCaptureControl, didUpdateZoomLevel zoomLevel: CGFloat) +} + +class CameraCaptureControl: UIView { + + var axis: NSLayoutConstraint.Axis = .horizontal { + didSet { + if oldValue != axis { + reactivateConstraintsForCurrentAxis() + invalidateIntrinsicContentSize() + } + } + } + private var horizontalAxisConstraints = [NSLayoutConstraint]() + private var verticalAxisConstraints = [NSLayoutConstraint]() + + let shutterButtonLayoutGuide = UILayoutGuide() // allows view controller to align to shutter button. + private let shutterButtonOuterCircle = CircleBlurView(effect: UIBlurEffect(style: .light)) + private let shutterButtonInnerCircle = CircleView() + + fileprivate static let recordingLockControlSize: CGFloat = 36 // Stop button, swipe tracking circle, lock icon + private static let shutterButtonDefaultSize: CGFloat = 72 + private static let shutterButtonRecordingSize: CGFloat = 122 + + private let outerCircleSizeConstraint: NSLayoutConstraint + private let innerCircleSizeConstraint: NSLayoutConstraint + private var slidingCircleHPositionConstraint: NSLayoutConstraint? + private var slidingCircleVPositionConstraint: NSLayoutConstraint? + + private lazy var slidingCircleView: CircleView = { + let view = CircleView(diameter: CameraCaptureControl.recordingLockControlSize) + view.backgroundColor = .ows_white + view.isHidden = true + return view + }() + private lazy var lockIconView = LockView(frame: CGRect(origin: .zero, size: .square(CameraCaptureControl.recordingLockControlSize))) + private lazy var stopButton: UIButton = { + let button = OWSButton { [weak self] in + guard let self = self else { return } + self.didTapStopButton() + } + button.backgroundColor = .white + button.dimsWhenHighlighted = true + button.layer.masksToBounds = true + button.layer.cornerRadius = 4 + button.isHidden = true + return button + }() + + weak var delegate: CameraCaptureControlDelegate? + + required init(axis: NSLayoutConstraint.Axis) { + innerCircleSizeConstraint = shutterButtonInnerCircle.autoSetDimension(.width, toSize: CameraCaptureControl.shutterButtonDefaultSize) + outerCircleSizeConstraint = shutterButtonOuterCircle.autoSetDimension(.width, toSize: CameraCaptureControl.shutterButtonDefaultSize) + + super.init(frame: CGRect(origin: .zero, size: CameraCaptureControl.intrinsicContentSize(forAxis: axis))) + + self.axis = axis + + // Round Shutter Button + addLayoutGuide(shutterButtonLayoutGuide) + shutterButtonLayoutGuide.widthAnchor.constraint(equalToConstant: CameraCaptureControl.shutterButtonDefaultSize).isActive = true + shutterButtonLayoutGuide.heightAnchor.constraint(equalToConstant: CameraCaptureControl.shutterButtonDefaultSize).isActive = true + horizontalAxisConstraints.append(contentsOf: [ + shutterButtonLayoutGuide.centerXAnchor.constraint(equalTo: leadingAnchor, constant: 0.5*CameraCaptureControl.shutterButtonDefaultSize), + shutterButtonLayoutGuide.topAnchor.constraint(equalTo: topAnchor), + shutterButtonLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor) + + ]) + verticalAxisConstraints.append(contentsOf: [ + shutterButtonLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor), + shutterButtonLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor), + shutterButtonLayoutGuide.centerYAnchor.constraint(equalTo: topAnchor, constant: 0.5*CameraCaptureControl.shutterButtonDefaultSize) + ]) + + addSubview(shutterButtonOuterCircle) + shutterButtonOuterCircle.centerXAnchor.constraint(equalTo: shutterButtonLayoutGuide.centerXAnchor).isActive = true + shutterButtonOuterCircle.centerYAnchor.constraint(equalTo: shutterButtonLayoutGuide.centerYAnchor).isActive = true + shutterButtonOuterCircle.autoPin(toAspectRatio: 1) + + addSubview(shutterButtonInnerCircle) + shutterButtonInnerCircle.autoPin(toAspectRatio: 1) + shutterButtonInnerCircle.isUserInteractionEnabled = false + shutterButtonInnerCircle.backgroundColor = .clear + shutterButtonInnerCircle.layer.borderColor = UIColor.ows_white.cgColor + shutterButtonInnerCircle.layer.borderWidth = 5 + shutterButtonInnerCircle.centerXAnchor.constraint(equalTo: shutterButtonOuterCircle.centerXAnchor).isActive = true + shutterButtonInnerCircle.centerYAnchor.constraint(equalTo: shutterButtonOuterCircle.centerYAnchor).isActive = true + + // The long press handles both the tap and the hold interaction, as well as the animation + // the presents as the user begins to hold (and the button begins to grow prior to recording) + let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)) + longPressGesture.minimumPressDuration = 0 + shutterButtonOuterCircle.addGestureRecognizer(longPressGesture) + + reactivateConstraintsForCurrentAxis() + } + + @available(*, unavailable, message: "Use init(axis:) instead") + required init?(coder: NSCoder) { + notImplemented() + } + + // MARK: - UI State + + enum State { + case initial + case recording + case recordingLocked + } + + private var _internalState: State = .initial + var state: State { + get { + _internalState + } + set { + setState(newValue) + } + } + + private var sliderTrackingProgress: CGFloat = 0 { + didSet { + if sliderTrackingProgress == 1 && state == .recording { + setState(.recordingLocked) // this will call updateUIForCurrentState() + } else { + updateUIForCurrentState() + } + } + } + + func setState(_ state: State, isRecordingWithLongPress: Bool = false, animationDuration: TimeInterval = 0) { + guard _internalState != state else { return } + + Logger.debug("New state: \(_internalState) -> \(state)") + + _internalState = state + self.isRecordingWithLongPress = isRecordingWithLongPress + if animationDuration > 0 { + UIView.animate(withDuration: animationDuration, + delay: 0, + options: [ .beginFromCurrentState ], + animations: { + self.updateUIForCurrentState() + }) + } else { + updateUIForCurrentState() + } + } + + private func updateUIForCurrentState() { + switch state { + case .initial: + // element visibility + if slidingCircleHPositionConstraint != nil { + stopButton.isHidden = true + slidingCircleView.isHidden = true + lockIconView.isHidden = true + lockIconView.state = .unlocked + } + shutterButtonInnerCircle.alpha = 1 + shutterButtonInnerCircle.backgroundColor = .clear + // element sizes + outerCircleSizeConstraint.constant = CameraCaptureControl.shutterButtonDefaultSize + innerCircleSizeConstraint.constant = CameraCaptureControl.shutterButtonDefaultSize + + case .recording: + prepareRecordingControlsIfNecessary() + let sliderProgress = isRecordingWithLongPress ? sliderTrackingProgress : 0 + // element visibility + stopButton.isHidden = sliderProgress == 0 + slidingCircleView.isHidden = sliderProgress == 0 + lockIconView.isHidden = !isRecordingWithLongPress + lockIconView.setState(sliderProgress > 0.5 ? .locking : .unlocked, animated: true) + shutterButtonInnerCircle.backgroundColor = .ows_white + // element sizes + outerCircleSizeConstraint.constant = CameraCaptureControl.shutterButtonRecordingSize + // Inner (white) circle gets smaller as user drags the slider and reveals stop button when the slider is halfway to the lock icon. + let circleSizeOffset = 2 * sliderProgress * (CameraCaptureControl.shutterButtonDefaultSize - CameraCaptureControl.recordingLockControlSize) + innerCircleSizeConstraint.constant = CameraCaptureControl.shutterButtonDefaultSize - circleSizeOffset + + case .recordingLocked: + prepareRecordingControlsIfNecessary() + // element visibility + stopButton.isHidden = false + slidingCircleView.isHidden = false + lockIconView.isHidden = false + lockIconView.setState(.locked, animated: true) + shutterButtonInnerCircle.alpha = 0 + shutterButtonInnerCircle.backgroundColor = .ows_white + // element sizes + outerCircleSizeConstraint.constant = CameraCaptureControl.shutterButtonRecordingSize + innerCircleSizeConstraint.constant = CameraCaptureControl.recordingLockControlSize + } + + setNeedsLayout() + layoutIfNeeded() + } + + private func prepareRecordingControlsIfNecessary() { + guard slidingCircleHPositionConstraint == nil else { return } + + // 1. Stop button. + addSubview(stopButton) + stopButton.autoPin(toAspectRatio: 1) + stopButton.autoSetDimension(.width, toSize: CameraCaptureControl.recordingLockControlSize) + stopButton.centerXAnchor.constraint(equalTo: shutterButtonLayoutGuide.centerXAnchor).isActive = true + stopButton.centerYAnchor.constraint(equalTo: shutterButtonLayoutGuide.centerYAnchor).isActive = true + + // 2. Slider. + insertSubview(slidingCircleView, belowSubview: shutterButtonInnerCircle) + slidingCircleView.translatesAutoresizingMaskIntoConstraints = false + let circleHPositionConstraint = slidingCircleView.centerXAnchor.constraint(equalTo: shutterButtonLayoutGuide.centerXAnchor) + var horizontalConstraints: [NSLayoutConstraint] = [ circleHPositionConstraint ] + horizontalConstraints.append(slidingCircleView.centerYAnchor.constraint(equalTo: shutterButtonLayoutGuide.centerYAnchor)) + slidingCircleHPositionConstraint = circleHPositionConstraint + + let circleVPositionConstraint = slidingCircleView.centerYAnchor.constraint(equalTo: shutterButtonLayoutGuide.centerYAnchor) + var verticalConstraints: [NSLayoutConstraint] = [ circleVPositionConstraint ] + verticalConstraints.append(slidingCircleView.centerXAnchor.constraint(equalTo: shutterButtonLayoutGuide.centerXAnchor)) + slidingCircleVPositionConstraint = circleVPositionConstraint + + // 3. Lock Icon + addSubview(lockIconView) + lockIconView.translatesAutoresizingMaskIntoConstraints = false + // Centered vertically, pinned to trailing edge. + horizontalConstraints.append(contentsOf: [ lockIconView.centerYAnchor.constraint(equalTo: shutterButtonLayoutGuide.centerYAnchor), + lockIconView.trailingAnchor.constraint(equalTo: trailingAnchor) ]) + // Centered horizontally, pinned to bottom edge. + verticalConstraints.append(contentsOf: [ lockIconView.centerXAnchor.constraint(equalTo: shutterButtonLayoutGuide.centerXAnchor), + lockIconView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) + + // 4. Activate current constraints. + horizontalAxisConstraints.append(contentsOf: horizontalConstraints) + if axis == .horizontal { + addConstraints(horizontalConstraints) + } + + verticalAxisConstraints.append(contentsOf: verticalConstraints) + if axis == .vertical { + addConstraints(verticalConstraints) + } + + setNeedsLayout() + UIView.performWithoutAnimation { + self.layoutIfNeeded() + } + } + + private func reactivateConstraintsForCurrentAxis() { + switch axis { + case .horizontal: + removeConstraints(verticalAxisConstraints) + addConstraints(horizontalAxisConstraints) + + case .vertical: + removeConstraints(horizontalAxisConstraints) + addConstraints(verticalAxisConstraints) + + @unknown default: + owsFailDebug("Unsupported `axis` value: \(axis.rawValue)") + } + } + + override var intrinsicContentSize: CGSize { + return Self.intrinsicContentSize(forAxis: axis) + } + + private static func intrinsicContentSize(forAxis axis: NSLayoutConstraint.Axis) -> CGSize { + switch axis { + case .horizontal: + return CGSize(width: CameraCaptureControl.shutterButtonDefaultSize + 64 + CameraCaptureControl.recordingLockControlSize, + height: CameraCaptureControl.shutterButtonDefaultSize) + + case .vertical: + return CGSize(width: CameraCaptureControl.shutterButtonDefaultSize, + height: CameraCaptureControl.shutterButtonDefaultSize + 64 + CameraCaptureControl.recordingLockControlSize) + + @unknown default: + owsFailDebug("Unsupported `axis` value: \(axis.rawValue)") + return CGSize(square: UIView.noIntrinsicMetric) + } + } + + // MARK: - Gestures + + private var isRecordingWithLongPress = false + private static let longPressDurationThreshold = 0.5 + private static let minDistanceBeforeActivatingLockSlider: CGFloat = 30 + private var initialTouchLocation: CGPoint? + private var initialZoomPosition: CGFloat? + private var touchTimer: Timer? + + @objc + private func handleLongPress(gesture: UILongPressGestureRecognizer) { + guard let gestureView = gesture.view else { + owsFailDebug("gestureView was unexpectedly nil") + return + } + + switch gesture.state { + case .possible: + break + + case .began: + guard state == .initial else { break } + + initialTouchLocation = gesture.location(in: gesture.view) + initialZoomPosition = nil + + touchTimer?.invalidate() + touchTimer = WeakTimer.scheduledTimer( + timeInterval: CameraCaptureControl.longPressDurationThreshold, + target: self, + userInfo: nil, + repeats: false + ) { [weak self] _ in + guard let self = self else { return } + + self.setState(.recording, isRecordingWithLongPress: true, animationDuration: 0.4) + + self.delegate?.cameraCaptureControlDidRequestStartVideoRecording(self) + } + + case .changed: + guard state == .recording else { break } + + guard let referenceDistance = delegate?.zoomScaleReferenceDistance else { + owsFailDebug("referenceHeight was unexpectedly nil") + return + } + + guard referenceDistance > 0 else { + owsFailDebug("referenceHeight was unexpectedly <= 0") + return + } + + guard let initialTouchLocation = initialTouchLocation else { + owsFailDebug("initialTouchLocation was unexpectedly nil") + return + } + + let currentLocation = gesture.location(in: gestureView) + + // Zoom - only use if slide to lock hasn't been activated. + var zoomLevel: CGFloat = 0 + if sliderTrackingProgress == 0 { + let minDistanceBeforeActivatingZoom: CGFloat = 30 + let currentDistance: CGFloat = { + switch axis { + case .horizontal: + if initialZoomPosition == nil { + initialZoomPosition = currentLocation.y + } + return initialZoomPosition! - currentLocation.y - minDistanceBeforeActivatingZoom + + case .vertical: + if initialZoomPosition == nil { + initialZoomPosition = currentLocation.x + } + return initialZoomPosition! - currentLocation.x - minDistanceBeforeActivatingZoom + + @unknown default: + owsFailDebug("Unsupported `axis` value: \(axis.rawValue)") + return 0 + } + }() + + let distanceForFullZoom = referenceDistance / 4 + let ratio = currentDistance / distanceForFullZoom + zoomLevel = ratio.clamp(0, 1) + delegate?.cameraCaptureControl(self, didUpdateZoomLevel: zoomLevel) + } else { + initialZoomPosition = nil + } + + // Video Recording Lock - only works if zoom level == 0 + if zoomLevel == 0 { + switch axis { + case .horizontal: + let xOffset = currentLocation.x - initialTouchLocation.x + updateHorizontalTracking(xOffset: xOffset) + + case .vertical: + let yOffset = currentLocation.y - initialTouchLocation.y + updateVerticalTracking(yOffset: yOffset) + + @unknown default: + owsFailDebug("Unsupported `axis` value: \(axis.rawValue)") + } + } + + case .ended: + touchTimer?.invalidate() + touchTimer = nil + + guard state != .recordingLocked else { return } + + if state == .recording { + setState(.initial, animationDuration: 0.2) + + delegate?.cameraCaptureControlDidRequestFinishVideoRecording(self) + } else { + delegate?.cameraCaptureControlDidRequestCapturePhoto(self) + } + + case .cancelled, .failed: + if state == .recording { + setState(.initial, animationDuration: 0.2) + + delegate?.cameraCaptureControlDidRequestCancelVideoRecording(self) + } + + touchTimer?.invalidate() + touchTimer = nil + + @unknown default: + owsFailDebug("unexpected gesture state: \(gesture.state.rawValue)") + } + } + + private func updateHorizontalTracking(xOffset: CGFloat) { + let effectiveDistance = xOffset - Self.minDistanceBeforeActivatingLockSlider + let distanceToLock = abs(lockIconView.center.x - stopButton.center.x) + if let positionConstraint = slidingCircleHPositionConstraint { + let trackingPosition = effectiveDistance.clamp(0, distanceToLock) + positionConstraint.constant = trackingPosition + } + sliderTrackingProgress = (effectiveDistance / distanceToLock).clamp(0, 1) + + Logger.debug("xOffset: \(xOffset), effectiveDistance: \(effectiveDistance), distanceToLock: \(distanceToLock), progress: \(sliderTrackingProgress)") + } + + private func updateVerticalTracking(yOffset: CGFloat) { + let effectiveDistance = yOffset - Self.minDistanceBeforeActivatingLockSlider + let distanceToLock = abs(lockIconView.center.y - stopButton.center.y) + if let positionConstraint = slidingCircleVPositionConstraint { + let trackingPosition = effectiveDistance.clamp(0, distanceToLock) + positionConstraint.constant = trackingPosition + } + sliderTrackingProgress = (effectiveDistance / distanceToLock).clamp(0, 1) + + Logger.debug("yOffset: \(yOffset), effectiveDistance: \(effectiveDistance), distanceToLock: \(distanceToLock), progress: \(sliderTrackingProgress)") + } + + // MARK: - Button Actions + + private func didTapStopButton() { + delegate?.cameraCaptureControlDidRequestFinishVideoRecording(self) + } +} + +private class LockView: UIView { + + private let imageViewLock = UIImageView(image: UIImage(named: "media-composer-lock-outline-24")) + private let blurBackgroundView = CircleBlurView(effect: UIBlurEffect(style: .dark)) + private let whiteBackgroundView = CircleView() + private let whiteCircleView = CircleView() + + enum State { + case unlocked + case locking + case locked + } + private var _internalState: State = .unlocked + var state: State { + get { + _internalState + } + set { + guard _internalState != newValue else { return } + setState(newValue) + } + } + + func setState(_ state: State, animated: Bool = false) { + _internalState = state + if animated { + UIView.animate(withDuration: 0.25, + delay: 0, + options: [ .beginFromCurrentState ]) { + self.updateAppearance() + } + } else { + updateAppearance() + } + } + + private func updateAppearance() { + switch state { + case .unlocked: + blurBackgroundView.alpha = 1 + whiteCircleView.alpha = 0 + whiteBackgroundView.alpha = 0 + imageViewLock.alpha = 1 + imageViewLock.tintColor = .ows_white + + case .locking: + blurBackgroundView.alpha = 1 + whiteCircleView.alpha = 1 + whiteBackgroundView.alpha = 0 + imageViewLock.alpha = 0 + + case .locked: + blurBackgroundView.alpha = 0 + whiteCircleView.alpha = 0 + whiteBackgroundView.alpha = 1 + imageViewLock.alpha = 1 + imageViewLock.tintColor = .ows_black + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + isUserInteractionEnabled = false + + addSubview(blurBackgroundView) + blurBackgroundView.autoPinEdgesToSuperviewEdges() + + addSubview(whiteCircleView) + whiteCircleView.backgroundColor = .clear + whiteCircleView.layer.borderColor = UIColor.ows_white.cgColor + whiteCircleView.layer.borderWidth = 3 + whiteCircleView.autoPinEdgesToSuperviewEdges() + + addSubview(whiteBackgroundView) + whiteBackgroundView.backgroundColor = .ows_white + whiteBackgroundView.autoPinEdgesToSuperviewEdges() + + addSubview(imageViewLock) + imageViewLock.tintColor = .ows_white + imageViewLock.autoCenterInSuperview() + + updateAppearance() + } + + @available(*, unavailable, message: "Use init(frame:) instead") + required init?(coder: NSCoder) { + notImplemented() + } + + override var intrinsicContentSize: CGSize { + CGSize(width: CameraCaptureControl.recordingLockControlSize, height: CameraCaptureControl.recordingLockControlSize) + } +} + +@available(iOS, deprecated: 13.0, message: "Use `overrideUserInterfaceStyle` instead.") +private protocol UserInterfaceStyleOverride { + + var userInterfaceStyleOverride: UIUserInterfaceStyle { get set } + + var effectiveUserInterfaceStyle: UIUserInterfaceStyle { get } + +} + +private extension UserInterfaceStyleOverride { + + var effectiveUserInterfaceStyle: UIUserInterfaceStyle { + if userInterfaceStyleOverride != .unspecified { + return userInterfaceStyleOverride + } + if let uiView = self as? UIView { + return uiView.traitCollection.userInterfaceStyle + } + return .unspecified + } + + static func blurEffectStyle(for userInterfaceStyle: UIUserInterfaceStyle) -> UIBlurEffect.Style { + switch userInterfaceStyle { + case .dark: + return .dark + case .light: + return .extraLight + default: + fatalError("It is an error to pass UIUserInterfaceStyleUnspecified.") + } + } + + static func tintColor(for userInterfaceStyle: UIUserInterfaceStyle) -> UIColor { + switch userInterfaceStyle { + case .dark: + return Theme.darkThemePrimaryColor + case .light: + return .ows_gray60 + default: + fatalError("It is an error to pass UIUserInterfaceStyleUnspecified.") + } + } +} + +class CameraOverlayButton: UIButton, UserInterfaceStyleOverride { + + fileprivate var userInterfaceStyleOverride: UIUserInterfaceStyle = .unspecified { + didSet { + if oldValue != userInterfaceStyleOverride { + updateStyle() + } + } + } + + private let backgroundView: CircleBlurView = { + let view = CircleBlurView(effect: UIBlurEffect(style: .regular)) + view.isUserInteractionEnabled = false + return view + }() + + private static let visibleButtonSize: CGFloat = 36 // both height and width + private static let defaultInset: CGFloat = 4 + + var contentInsets: UIEdgeInsets = UIEdgeInsets(margin: CameraOverlayButton.defaultInset) { + didSet { + layoutMargins = contentInsets + } + } + + required init(image: UIImage?, userInterfaceStyleOverride: UIUserInterfaceStyle = .unspecified) { + super.init(frame: CGRect(origin: .zero, size: .square(Self.visibleButtonSize + 2*Self.defaultInset))) + + self.userInterfaceStyleOverride = userInterfaceStyleOverride + + layoutMargins = contentInsets + + addSubview(backgroundView) + backgroundView.autoPinEdgesToSuperviewMargins() + + setImage(image, for: .normal) + updateStyle() + } + + @available(*, unavailable, message: "Use init(image:userInterfaceStyleOverride:) instead") + override init(frame: CGRect) { + notImplemented() + } + + @available(*, unavailable, message: "Use init(image:userInterfaceStyleOverride:) instead") + required init?(coder: NSCoder) { + notImplemented() + } + + override func layoutSubviews() { + super.layoutSubviews() + sendSubviewToBack(backgroundView) + } + + override var intrinsicContentSize: CGSize { + return CGSize(width: Self.visibleButtonSize + layoutMargins.leading + layoutMargins.trailing, + height: Self.visibleButtonSize + layoutMargins.top + layoutMargins.bottom) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle, userInterfaceStyleOverride == .unspecified { + updateStyle() + } + } + + private func updateStyle() { + backgroundView.effect = UIBlurEffect(style: CameraOverlayButton.blurEffectStyle(for: effectiveUserInterfaceStyle)) + tintColor = CameraOverlayButton.tintColor(for: effectiveUserInterfaceStyle) + } +} + +class MediaDoneButton: UIButton, UserInterfaceStyleOverride { + + var badgeNumber: Int = 0 { + didSet { + textLabel.text = numberFormatter.string(for: badgeNumber) + invalidateIntrinsicContentSize() + } + } + + var userInterfaceStyleOverride: UIUserInterfaceStyle = .unspecified { + didSet { + if oldValue != userInterfaceStyleOverride { + updateStyle() + } + } + } + + private static var font: UIFont { + return UIFont.ows_dynamicTypeSubheadline.ows_monospaced + } + + private let numberFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .decimal + return numberFormatter + }() + + private let textLabel: UILabel = { + let label = UILabel() + label.textColor = .ows_white + label.textAlignment = .center + label.font = MediaDoneButton.font + return label + }() + private let pillView: PillView = { + let pillView = PillView(frame: .zero) + pillView.isUserInteractionEnabled = false + pillView.layoutMargins = UIEdgeInsets(hMargin: 8, vMargin: 7) + return pillView + }() + private let blurBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) + private let chevronImageView: UIImageView = { + let image: UIImage? + if #available(iOS 13, *) { + image = CurrentAppContext().isRTL ? UIImage(systemName: "chevron.backward") : UIImage(systemName: "chevron.right") + } else { + image = CurrentAppContext().isRTL ? UIImage(named: "chevron-left-20") : UIImage(named: "chevron-right-20") + } + let chevronImageView = UIImageView(image: image!.withRenderingMode(.alwaysTemplate)) + chevronImageView.contentMode = .center + if #available(iOS 13, *) { + chevronImageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: MediaDoneButton.font.pointSize) + } + return chevronImageView + }() + private var dimmerView: UIView? + + override init(frame: CGRect) { + super.init(frame: frame) + + addSubview(pillView) + pillView.autoPinEdgesToSuperviewEdges() + + pillView.addSubview(blurBackgroundView) + blurBackgroundView.autoPinEdgesToSuperviewEdges() + + let blueBadgeView = PillView(frame: bounds) + blueBadgeView.backgroundColor = .ows_accentBlue + blueBadgeView.layoutMargins = UIEdgeInsets(margin: 4) + blueBadgeView.addSubview(textLabel) + textLabel.autoPinEdgesToSuperviewMargins() + + let hStack = UIStackView(arrangedSubviews: [blueBadgeView, chevronImageView]) + hStack.spacing = 6 + pillView.addSubview(hStack) + hStack.autoPinEdgesToSuperviewMargins() + + updateStyle() + } + + @available(*, unavailable, message: "Use init(frame:) instead") + required init?(coder: NSCoder) { + notImplemented() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory { + textLabel.font = .ows_dynamicTypeSubheadline.ows_monospaced + if #available(iOS 13, *) { + chevronImageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: textLabel.font.pointSize) + } + } + if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle { + updateStyle() + } + } + + override var isHighlighted: Bool { + didSet { + if isHighlighted { + if dimmerView == nil { + let dimmerView = UIView(frame: bounds) + dimmerView.isUserInteractionEnabled = false + dimmerView.backgroundColor = .ows_black + pillView.addSubview(dimmerView) + dimmerView.autoPinEdgesToSuperviewEdges() + self.dimmerView = dimmerView + } + dimmerView?.alpha = 0.5 + } else if let dimmerView = dimmerView { + dimmerView.alpha = 0 + } + } + } + + private func updateStyle() { + blurBackgroundView.effect = UIBlurEffect(style: MediaDoneButton.blurEffectStyle(for: effectiveUserInterfaceStyle)) + chevronImageView.tintColor = MediaDoneButton.tintColor(for: effectiveUserInterfaceStyle) + } +} diff --git a/Signal/src/ViewControllers/Photos/PhotoCapture.swift b/Signal/src/ViewControllers/Photos/PhotoCapture.swift index ffc4cf9761..1ca853ce0c 100644 --- a/Signal/src/ViewControllers/Photos/PhotoCapture.swift +++ b/Signal/src/ViewControllers/Photos/PhotoCapture.swift @@ -1,35 +1,36 @@ // -// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// Copyright (c) 2022 Open Whisper Systems. All rights reserved. // -import Foundation import CoreServices +import Foundation protocol PhotoCaptureDelegate: AnyObject { // MARK: Still Photo - func photoCaptureDidStartPhotoCapture(_ photoCapture: PhotoCapture) - func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessingAttachment attachment: SignalAttachment) - func photoCapture(_ photoCapture: PhotoCapture, processingDidError error: Error) + func photoCaptureDidStart(_ photoCapture: PhotoCapture) + func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessing attachment: SignalAttachment) + func photoCapture(_ photoCapture: PhotoCapture, didFailProcessing error: Error) - // MARK: Movie + // MARK: Video - func photoCaptureDidBeginMovie(_ photoCapture: PhotoCapture) - func photoCaptureDidCompleteMovie(_ photoCapture: PhotoCapture) - func photoCaptureDidCancelMovie(_ photoCapture: PhotoCapture) + func photoCaptureWillBeginRecording(_ photoCapture: PhotoCapture) + func photoCaptureDidBeginRecording(_ photoCapture: PhotoCapture) + func photoCaptureDidFinishRecording(_ photoCapture: PhotoCapture) + func photoCaptureDidCancelRecording(_ photoCapture: PhotoCapture) // MARK: Utility func photoCapture(_ photoCapture: PhotoCapture, didChangeOrientation: AVCaptureVideoOrientation) func photoCaptureCanCaptureMoreItems(_ photoCapture: PhotoCapture) -> Bool func photoCaptureDidTryToCaptureTooMany(_ photoCapture: PhotoCapture) - var zoomScaleReferenceHeight: CGFloat? { get } + var zoomScaleReferenceDistance: CGFloat? { get } func beginCaptureButtonAnimation(_ duration: TimeInterval) func endCaptureButtonAnimation(_ duration: TimeInterval) - func photoCapture(_ photoCapture: PhotoCapture, didCompleteFocusingAtPoint focusPoint: CGPoint) + func photoCapture(_ photoCapture: PhotoCapture, didCompleteFocusing focusPoint: CGPoint) } @@ -82,7 +83,7 @@ class PhotoCapture: NSObject { let focusPoint = currentCaptureInput.device.focusPointOfInterest DispatchQueue.main.async { - self.delegate?.photoCapture(self, didCompleteFocusingAtPoint: focusPoint) + self.delegate?.photoCapture(self, didCompleteFocusing: focusPoint) } } @@ -292,20 +293,20 @@ class PhotoCapture: NSObject { focusObservation.invalidate() } self.focusObservation = newInput.observe(\.device.isAdjustingFocus, - options: [.old, .new]) { [weak self] _, change in - guard let self = self else { return } + options: [.old, .new]) { [weak self] _, change in + guard let self = self else { return } - guard let oldValue = change.oldValue else { - return - } + guard let oldValue = change.oldValue else { + return + } - guard let newValue = change.newValue else { - return - } + guard let newValue = change.newValue else { + return + } - if oldValue == true && newValue == false { - self.didCompleteFocusing() - } + if oldValue == true && newValue == false { + self.didCompleteFocusing() + } } currentCaptureInput = newInput @@ -333,9 +334,9 @@ class PhotoCapture: NSObject { } public func focus(with focusMode: AVCaptureDevice.FocusMode, - exposureMode: AVCaptureDevice.ExposureMode, - at devicePoint: CGPoint, - monitorSubjectAreaChange: Bool) { + exposureMode: AVCaptureDevice.ExposureMode, + at devicePoint: CGPoint, + monitorSubjectAreaChange: Bool) { sessionQueue.async { Logger.debug("focusMode: \(focusMode), exposureMode: \(exposureMode), devicePoint: \(devicePoint), monitorSubjectAreaChange:\(monitorSubjectAreaChange)") guard let device = self.captureDevice else { @@ -460,7 +461,7 @@ class PhotoCapture: NSObject { } let captureRect = captureOutputPhotoRect - delegate.photoCaptureDidStartPhotoCapture(self) + delegate.photoCaptureDidStart(self) sessionQueue.async { self.captureOutput.takePhoto(delegate: self, captureRect: captureRect) } @@ -516,11 +517,13 @@ class PhotoCapture: NSObject { throw PhotoCaptureError.invalidVideo } - self.delegate?.photoCaptureDidBeginMovie(self) + self.delegate?.photoCaptureDidBeginRecording(self) } }.catch { error in self.handleMovieCaptureError(error) } + + delegate.photoCaptureWillBeginRecording(self) } private func completeMovieCapture() { @@ -555,7 +558,7 @@ class PhotoCapture: NSObject { // Inform UI that capture is stopping. self.lastMovieRecordingEndDate = movieRecordingStartDate - self.delegate?.photoCaptureDidCompleteMovie(self) + self.delegate?.photoCaptureDidFinishRecording(self) }.catch { error in self.handleMovieCaptureError(error) } @@ -573,7 +576,7 @@ class PhotoCapture: NSObject { self.shouldHaveAudioCapture = false } self.lastMovieRecordingEndDate = Date() - self.delegate?.photoCapture(self, processingDidError: error) + self.delegate?.photoCapture(self, didFailProcessing: error) } private func cancelMovieCapture() { @@ -592,7 +595,7 @@ class PhotoCapture: NSObject { // Inform UI that capture is stopping. self.lastMovieRecordingEndDate = Date() - self.delegate?.photoCaptureDidCancelMovie(self) + self.delegate?.photoCaptureDidCancelRecording(self) }.catch { error in self.handleMovieCaptureError(error) } @@ -668,33 +671,34 @@ extension PhotoCapture: VolumeButtonObserver { // MARK: - -extension PhotoCapture: CaptureButtonDelegate { - func didTapCaptureButton(_ captureButton: CaptureButton) { +extension PhotoCapture: CameraCaptureControlDelegate { + + func cameraCaptureControlDidRequestCapturePhoto(_ control: CameraCaptureControl) { takePhoto() } - func didBeginLongPressCaptureButton(_ captureButton: CaptureButton) { + func cameraCaptureControlDidRequestStartVideoRecording(_ control: CameraCaptureControl) { beginMovieCapture() } - func didCompleteLongPressCaptureButton(_ captureButton: CaptureButton) { + func cameraCaptureControlDidRequestFinishVideoRecording(_ control: CameraCaptureControl) { completeMovieCapture() } - func didCancelLongPressCaptureButton(_ captureButton: CaptureButton) { + func cameraCaptureControlDidRequestCancelVideoRecording(_ control: CameraCaptureControl) { cancelMovieCapture() } - func didPressStopCaptureButton(_ captureButton: CaptureButton) { + func didPressStopCaptureButton(_ control: CameraCaptureControl) { completeMovieCapture() } - var zoomScaleReferenceHeight: CGFloat? { - return delegate?.zoomScaleReferenceHeight + var zoomScaleReferenceDistance: CGFloat? { + return delegate?.zoomScaleReferenceDistance } - func longPressCaptureButton(_ captureButton: CaptureButton, didUpdateZoomAlpha zoomAlpha: CGFloat) { - updateZoom(alpha: zoomAlpha) + func cameraCaptureControl(_ control: CameraCaptureControl, didUpdateZoomLevel zoomLevel: CGFloat) { + updateZoom(alpha: zoomLevel) } } @@ -730,12 +734,12 @@ extension PhotoCapture: CaptureOutputDelegate { switch photoData { case .failure(let error): - delegate.photoCapture(self, processingDidError: error) + delegate.photoCapture(self, didFailProcessing: error) case .success(let photoData): let dataSource = DataSourceValue.dataSource(with: photoData, utiType: kUTTypeJPEG as String) let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String) - delegate.photoCapture(self, didFinishProcessingAttachment: attachment) + delegate.photoCapture(self, didFinishProcessing: attachment) } } @@ -761,7 +765,7 @@ extension PhotoCapture: CaptureOutputDelegate { let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String) BenchEventComplete(eventId: "Movie Processing") - delegate.photoCapture(self, didFinishProcessingAttachment: attachment) + delegate.photoCapture(self, didFinishProcessing: attachment) } } @@ -1165,7 +1169,7 @@ class PhotoCaptureOutputAdaptee: NSObject, ImageCaptureOutput { photoSettings.isHighResolutionPhotoEnabled = true photoSettings.isAutoStillImageStabilizationEnabled = - photoOutput.isStillImageStabilizationSupported + photoOutput.isStillImageStabilizationSupported return photoSettings } diff --git a/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift b/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift index 55ec41721e..d284d7cfcd 100644 --- a/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift +++ b/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift @@ -1,17 +1,27 @@ // -// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// Copyright (c) 2022 Open Whisper Systems. All rights reserved. // -import Foundation import AVFoundation +import Foundation import Lottie +import Photos +import UIKit +import SignalMessaging protocol PhotoCaptureViewControllerDelegate: AnyObject { - func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: SignalAttachment) + func photoCaptureViewControllerDidFinish(_ photoCaptureViewController: PhotoCaptureViewController) func photoCaptureViewControllerDidCancel(_ photoCaptureViewController: PhotoCaptureViewController) func photoCaptureViewControllerDidTryToCaptureTooMany(_ photoCaptureViewController: PhotoCaptureViewController) + func photoCaptureViewControllerViewWillAppear(_ photoCaptureViewController: PhotoCaptureViewController) func photoCaptureViewControllerCanCaptureMoreItems(_ photoCaptureViewController: PhotoCaptureViewController) -> Bool - func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, isRecordingMovie: Bool) + func photoCaptureViewControllerDidRequestPresentPhotoLibrary(_ photoCaptureViewController: PhotoCaptureViewController) + func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didRequestSwitchBatchMode batchMode: Bool) -> Bool +} + +protocol PhotoCaptureViewControllerDataSource: AnyObject { + var numberOfMediaItems: Int { get } + func addMedia(attachment: SignalAttachment) } enum PhotoCaptureError: Error { @@ -34,25 +44,13 @@ extension PhotoCaptureError: LocalizedError, UserErrorDescriptionProvider { } } -// MARK: - - -@objc class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate { weak var delegate: PhotoCaptureViewControllerDelegate? - var interactiveDismiss: PhotoCaptureInteractiveDismiss! + weak var dataSource: PhotoCaptureViewControllerDataSource? + private var interactiveDismiss: PhotoCaptureInteractiveDismiss? - @objc public lazy var photoCapture = PhotoCapture() - - lazy var tapToFocusView: AnimationView = { - let view = AnimationView(name: "tap_to_focus") - view.animationSpeed = 1 - view.backgroundBehavior = .forceFinish - view.contentMode = .scaleAspectFit - view.autoSetDimensions(to: CGSize(square: 150)) - view.setContentHuggingHigh() - return view - }() + public lazy var photoCapture = PhotoCapture() deinit { UIDevice.current.endGeneratingDeviceOrientationNotifications() @@ -64,28 +62,19 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate // MARK: - Overrides override func loadView() { - self.view = UIView() - self.view.backgroundColor = Theme.darkThemeBackgroundColor + view = UIView() + view.backgroundColor = Theme.darkThemeBackgroundColor + view.preservesSuperviewLayoutMargins = true + definesPresentationContext = true - view.addSubview(previewView) + initializeUI() + } - previewView.autoPinEdgesToSuperviewEdges() - - view.addSubview(captureButton) - if UIDevice.current.isIPad { - captureButton.autoVCenterInSuperview() - captureButton.centerXAnchor.constraint(equalTo: view.trailingAnchor, constant: SendMediaNavigationController.bottomButtonsCenterOffset).isActive = true - captureButton.movieLockView.autoSetDimension(.width, toSize: 120) - } else { - captureButton.autoHCenterInSuperview() - // we pin to edges rather than margin, because on notched devices the margin changes - // as the device rotates *EVEN THOUGH* the interface is locked to portrait. - captureButton.centerYAnchor.constraint(equalTo: view.bottomAnchor, - constant: SendMediaNavigationController.bottomButtonsCenterOffset).isActive = true - captureButton.movieLockView.autoPinEdge(.trailing, to: .trailing, of: view, withOffset: -16) - } + override func viewDidLoad() { + super.viewDidLoad() + setupPhotoCapture() // If the view is already visible, setup the volume button listener // now that the capture UI is ready. Otherwise, we'll wait until // we're visible. @@ -93,27 +82,6 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate VolumeButtons.shared?.addObserver(observer: photoCapture) } - view.addSubview(tapToFocusView) - tapToFocusView.isUserInteractionEnabled = false - tapToFocusLeftConstraint = tapToFocusView.centerXAnchor.constraint(equalTo: view.leftAnchor) - tapToFocusLeftConstraint.isActive = true - tapToFocusTopConstraint = tapToFocusView.centerYAnchor.constraint(equalTo: view.topAnchor) - tapToFocusTopConstraint.isActive = true - - view.addSubview(topBar) - topBar.autoPinWidthToSuperview() - topBarOffset = topBar.autoPinEdge(toSuperviewEdge: .top) - topBar.autoSetDimension(.height, toSize: 44) - } - - var topBarOffset: NSLayoutConstraint! - - override func viewDidLoad() { - super.viewDidLoad() - - setupPhotoCapture() - - updateNavigationItems() updateFlashModeControl() view.addGestureRecognizer(pinchZoomGesture) @@ -121,17 +89,27 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate view.addGestureRecognizer(doubleTapToSwitchCameraGesture) if let navController = self.navigationController { - interactiveDismiss = PhotoCaptureInteractiveDismiss(viewController: navController) + let interactiveDismiss = PhotoCaptureInteractiveDismiss(viewController: navController) interactiveDismiss.interactiveDismissDelegate = self interactiveDismiss.addGestureRecognizer(to: view) + self.interactiveDismiss = interactiveDismiss } tapToFocusGesture.require(toFail: doubleTapToSwitchCameraGesture) + + bottomBar.photoLibraryButton.configure() + if let sideBar = sideBar { + sideBar.photoLibraryButton.configure() + } } private var isVisible = false + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + + delegate?.photoCaptureViewControllerViewWillAppear(self) + isVisible = true let previewOrientation: AVCaptureVideoOrientation if UIDevice.current.isIPad { @@ -143,10 +121,16 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate photoCapture.updateVideoPreviewConnection(toOrientation: previewOrientation) updateIconOrientations(isAnimated: false, captureOrientation: previewOrientation) resumePhotoCapture() + + if let dataSource = dataSource, dataSource.numberOfMediaItems > 0 { + isInBatchMode = true + } + updateDoneButtonAppearance() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + if hasCaptureStarted { BenchEventComplete(eventId: "Show-Camera") VolumeButtons.shared?.addObserver(observer: photoCapture) @@ -156,6 +140,7 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + isVisible = false VolumeButtons.shared?.removeObserver(photoCapture) pausePhotoCapture() @@ -166,12 +151,11 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate guard !CurrentAppContext().hasActiveCall else { return false } - return true } - override var prefersHomeIndicatorAutoHidden: Bool { - return true + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return .portrait } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -182,151 +166,273 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate // Rotating the preview layer is really distracting, so we fade out the preview layer // while the rotation occurs. self.previewView.alpha = 0 - coordinator.animate(alongsideTransition: { _ in }) { _ in + coordinator.animate(alongsideTransition: { _ in }, + completion: { _ in UIView.animate(withDuration: 0.1) { self.previewView.alpha = 1 } - } + }) } } override func viewSafeAreaInsetsDidChange() { super.viewSafeAreaInsetsDidChange() - if !UIDevice.current.isIPad { - // we pin to a constant rather than margin, because on notched devices the - // safeAreaInsets/margins change as the device rotates *EVEN THOUGH* the interface - // is locked to portrait. - // Only grab this once -- otherwise when we swipe to dismiss this is updated and the top bar jumps to having zero offset - if topBarOffset.constant == 0 { - topBarOffset.constant = max(view.safeAreaInsets.top, view.safeAreaInsets.left, view.safeAreaInsets.bottom) - } + + // we pin to a constant rather than margin, because on notched devices the + // safeAreaInsets/margins change as the device rotates *EVEN THOUGH* the interface + // is locked to portrait. + // Only grab this once -- otherwise when we swipe to dismiss this is updated and the top bar jumps to having zero offset + if topBarOffsetFromTop.constant == 0 { + let maxInsetDimension = max(view.safeAreaInsets.top, view.safeAreaInsets.left, view.safeAreaInsets.bottom) + topBarOffsetFromTop.constant = max(maxInsetDimension, previewView.frame.minY) } } - func interactiveDismissDidBegin(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) { - } - func interactiveDismissDidFinish(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) { - dismiss(animated: true) - } - func interactiveDismissDidCancel(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) { + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + isIPadUIInRegularMode = traitCollection.horizontalSizeClass == .regular && traitCollection.verticalSizeClass == .regular } - // MARK: - - var isRecordingMovie: Bool = false + // MARK: - Layout Code - private class TopBar: UIView { - let recordingTimerView = RecordingTimerView() - let navStack: UIStackView - - init(navbarItems: [UIView]) { - self.navStack = UIStackView(arrangedSubviews: navbarItems) - navStack.spacing = 16 - - super.init(frame: .zero) - - addSubview(navStack) - navStack.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 16)) - - addSubview(recordingTimerView) - recordingTimerView.isHidden = true - recordingTimerView.autoCenterInSuperview() + private var isIPadUIInRegularMode = false { + didSet { + guard oldValue != isIPadUIInRegularMode else { return } + updateIPadInterfaceLayout() } + } - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + private var isRecordingVideo: Bool = false { + didSet { + if isRecordingVideo { + topBar.mode = .videoRecording + topBar.recordingTimerView.startCounting() - enum Mode { - case navigation, recordingMovie - } + bottomBar.captureControl.setState(.recording, animationDuration: 0.4) + if let sideBar = sideBar { + sideBar.cameraCaptureControl.setState(.recording, animationDuration: 0.4) + } + } else { + topBar.mode = isIPadUIInRegularMode ? .closeButton : .cameraControls + topBar.recordingTimerView.stopCounting() - var mode: Mode = .navigation { - didSet { - switch mode { - case .recordingMovie: - navStack.isHidden = true - recordingTimerView.sizeToFit() - recordingTimerView.isHidden = false - case .navigation: - navStack.isHidden = false - recordingTimerView.isHidden = true + bottomBar.captureControl.setState(.initial, animationDuration: 0.2) + if let sideBar = sideBar { + sideBar.cameraCaptureControl.setState(.initial, animationDuration: 0.2) } } + + bottomBar.isRecordingVideo = isRecordingVideo + if let sideBar = sideBar { + sideBar.isRecordingVideo = isRecordingVideo + } + + doneButton.isHidden = isRecordingVideo || doneButton.badgeNumber == 0 } } - private lazy var topBar: TopBar = { - let dismissButton: UIButton - if UIDevice.current.isIPad { - dismissButton = OWSButton.shadowedCancelButton { [weak self] in - self?.didTapClose() - } - dismissButton.contentEdgeInsets = UIEdgeInsets(top: 7, leading: 20, bottom: 6, trailing: 20) - } else { - dismissButton = dismissControl.button - dismissButton.contentEdgeInsets = UIEdgeInsets(top: 1, leading: 16, bottom: 6, trailing: 20) - } + func switchToBatchMode() { + isInBatchMode = true + } - return TopBar(navbarItems: [dismissButton, - UIView.hStretchingSpacer(), - switchCameraControl.button, - flashModeControl.button]) + private(set) var isInBatchMode: Bool = false { + didSet { + let buttonImage = isInBatchMode ? ButtonImages.batchModeOn : ButtonImages.batchModeOff + topBar.batchModeButton.setImage(buttonImage, for: .normal) + if let sideBar = sideBar { + sideBar.batchModeButton.setImage(buttonImage, for: .normal) + } + } + } + + private let topBar = TopBar(frame: .zero) + private var topBarOffsetFromTop: NSLayoutConstraint! + + private let bottomBar = BottomBar(frame: .zero) + private var bottomBarVerticalPositionConstraint: NSLayoutConstraint! + + private var sideBar: SideBar? // Optional because most devices are iPhones and will never need this. + + private lazy var tapToFocusView: AnimationView = { + let view = AnimationView(name: "tap_to_focus") + view.animationSpeed = 1 + view.backgroundBehavior = .forceFinish + view.contentMode = .scaleAspectFit + view.autoSetDimensions(to: CGSize(square: 150)) + view.setContentHuggingHigh() + return view }() - func updateNavigationItems() { - if isRecordingMovie { - topBar.mode = .recordingMovie - } else { - topBar.mode = .navigation - } - } - - override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - return .portrait - } - - // MARK: - Views - - let captureButton = CaptureButton() - - var previewView: CapturePreviewView { + private var previewView: CapturePreviewView { return photoCapture.previewView } - class PhotoControl { - let button: OWSButton + private lazy var doneButton: MediaDoneButton = { + let button = MediaDoneButton(type: .custom) + button.badgeNumber = 0 + button.userInterfaceStyleOverride = .dark + return button + }() + private var doneButtonIPhoneConstraints: [NSLayoutConstraint]! + private var doneButtonIPadConstraints: [NSLayoutConstraint]! - init(imageName: String, block: @escaping () -> Void) { - self.button = OWSButton(imageName: imageName, tintColor: .ows_white, block: block) - button.setCompressionResistanceHigh() - button.layer.shadowOffset = CGSize.zero - button.layer.shadowOpacity = 0.35 - button.layer.shadowRadius = 4 - button.contentEdgeInsets = UIEdgeInsets(top: 6, leading: 4, bottom: 0, trailing: 4) + private func initializeUI() { + // Step 1. Initialize all UI elements for iPhone layout (which can also be used on an iPad). + + view.addSubview(previewView) + + view.addSubview(topBar) + topBar.mode = .cameraControls + topBar.closeButton.addTarget(self, action: #selector(didTapClose), for: .touchUpInside) + topBar.batchModeButton.addTarget(self, action: #selector(didTapBatchMode), for: .touchUpInside) + topBar.flashModeButton.addTarget(self, action: #selector(didTapFlashMode), for: .touchUpInside) + topBar.autoPinWidthToSuperview() + topBarOffsetFromTop = topBar.autoPinEdge(toSuperviewEdge: .top) + + view.addSubview(bottomBar) + bottomBar.isCompactHeightLayout = !UIDevice.current.hasIPhoneXNotch + bottomBar.switchCameraButton.addTarget(self, action: #selector(didTapSwitchCamera), for: .touchUpInside) + bottomBar.photoLibraryButton.addTarget(self, action: #selector(didTapPhotoLibrary), for: .touchUpInside) + bottomBar.autoPinWidthToSuperview() + if bottomBar.isCompactHeightLayout { + // On devices with home button bar is simply pinned to the bottom of the screen + // with a margin that defines space under the shutter button. + view.bottomAnchor.constraint(equalTo: bottomBar.bottomAnchor, constant: 32).isActive = true + } else { + // On `notch` devices: + // i. Shutter button is placed 16 pts above the bottom edge of the preview view. + previewView.bottomAnchor.constraint(equalTo: bottomBar.shutterButtonLayoutGuide.bottomAnchor, constant: 16).isActive = true + + // ii. Other buttons are centered vertically in the black box between + // bottom of the preview view and top of bottom safe area. + bottomBarVerticalPositionConstraint = bottomBar.controlButtonsLayoutGuide.centerYAnchor.constraint(equalTo: previewView.bottomAnchor) + view.addConstraint(bottomBarVerticalPositionConstraint) } - func setImage(imageName: String) { - button.setImage(imageName: imageName) + view.addSubview(doneButton) + doneButton.isHidden = true + doneButton.translatesAutoresizingMaskIntoConstraints = false + doneButtonIPhoneConstraints = [ doneButton.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), + doneButton.centerYAnchor.constraint(equalTo: bottomBar.shutterButtonLayoutGuide.centerYAnchor) ] + view.addConstraints(doneButtonIPhoneConstraints) + doneButton.addTarget(self, action: #selector(didTapDoneButton), for: .touchUpInside) + + view.addSubview(tapToFocusView) + tapToFocusView.isUserInteractionEnabled = false + tapToFocusLeftConstraint = tapToFocusView.centerXAnchor.constraint(equalTo: view.leftAnchor) + tapToFocusLeftConstraint.isActive = true + tapToFocusTopConstraint = tapToFocusView.centerYAnchor.constraint(equalTo: view.topAnchor) + tapToFocusTopConstraint.isActive = true + + // Step 2. Check if we're running on an iPad and update UI accordingly. + // Note that `traitCollectionDidChange` won't be called during initial view loading process. + isIPadUIInRegularMode = traitCollection.horizontalSizeClass == .regular && traitCollection.verticalSizeClass == .regular + + // This background footer doesn't let view controller underneath current VC + // to be visible at the bottom of the screen during interactive dismiss. + if UIDevice.current.hasIPhoneXNotch { + let blackFooter = UIView() + blackFooter.backgroundColor = view.backgroundColor + view.insertSubview(blackFooter, at: 0) + blackFooter.autoPinWidthToSuperview() + blackFooter.autoPinEdge(toSuperviewEdge: .bottom) + blackFooter.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.5).isActive = true } } - private lazy var dismissControl: PhotoControl = { - return PhotoControl(imageName: "ic_x_with_shadow") { [weak self] in - self?.didTapClose() - } - }() + private func initializeIPadSpecificUIIfNecessary() { + guard sideBar == nil else { return } - private lazy var switchCameraControl: PhotoControl = { - return PhotoControl(imageName: "ic_switch_camera") { [weak self] in - self?.didTapSwitchCamera() - } - }() + let sideBar = SideBar(frame: .zero) + sideBar.cameraCaptureControl.delegate = photoCapture + sideBar.batchModeButton.addTarget(self, action: #selector(didTapBatchMode), for: .touchUpInside) + sideBar.flashModeButton.addTarget(self, action: #selector(didTapFlashMode), for: .touchUpInside) + sideBar.switchCameraButton.addTarget(self, action: #selector(didTapSwitchCamera), for: .touchUpInside) + sideBar.photoLibraryButton.addTarget(self, action: #selector(didTapPhotoLibrary), for: .touchUpInside) + view.addSubview(sideBar) + sideBar.autoPinTrailingToSuperviewMargin() + sideBar.cameraCaptureControl.shutterButtonLayoutGuide.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true + self.sideBar = sideBar - private lazy var flashModeControl: PhotoControl = { - return PhotoControl(imageName: "ic_flash_mode_auto") { [weak self] in - self?.didTapFlashMode() + sideBar.batchModeButton.setImage(isInBatchMode ? ButtonImages.batchModeOn : ButtonImages.batchModeOff, for: .normal) + updateFlashModeControl() + + doneButtonIPadConstraints = [ doneButton.centerXAnchor.constraint(equalTo: sideBar.centerXAnchor), + doneButton.bottomAnchor.constraint(equalTo: sideBar.topAnchor, constant: -16)] + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + guard !(interactiveDismiss?.interactionInProgress ?? false) else { return } + + // Clamp capture view to 16:9 on iPhones. + var previewFrame = view.bounds + var cornerRadius: CGFloat = 0 + if !UIDevice.current.isIPad { + let targetAspectRatio: CGFloat = 16/9 + let currentAspectRatio: CGFloat = previewFrame.height / previewFrame.width + + if abs(currentAspectRatio - targetAspectRatio) > 0.001 { + previewFrame.y = view.safeAreaInsets.top + previewFrame.height = previewFrame.width * targetAspectRatio + cornerRadius = 18 + } } - }() + previewView.frame = previewFrame + previewView.previewLayer.cornerRadius = cornerRadius + + // See comment in `initializeUI`. + if !bottomBar.isCompactHeightLayout { + let blackBarHeight = view.bounds.maxY - previewFrame.maxY - view.safeAreaInsets.bottom + bottomBarVerticalPositionConstraint.constant = 0.5 * blackBarHeight + } + } + + private func updateIPadInterfaceLayout() { + owsAssertDebug(UIDevice.current.isIPad) + + if isIPadUIInRegularMode { + initializeIPadSpecificUIIfNecessary() + + view.removeConstraints(doneButtonIPhoneConstraints) + view.addConstraints(doneButtonIPadConstraints) + } else { + view.removeConstraints(doneButtonIPadConstraints) + view.addConstraints(doneButtonIPhoneConstraints) + } + + if !isRecordingVideo { + topBar.mode = isIPadUIInRegularMode ? .closeButton : .cameraControls + } + bottomBar.isHidden = isIPadUIInRegularMode + sideBar?.isHidden = !isIPadUIInRegularMode + } + + func updateDoneButtonAppearance () { + if isInBatchMode, let badgeNumber = dataSource?.numberOfMediaItems { + doneButton.badgeNumber = badgeNumber + doneButton.isHidden = false + } else { + doneButton.isHidden = true + } + } + + // MARK: - Interactive Dismiss + + func interactiveDismissDidBegin(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) { + view.backgroundColor = .clear + } + + func interactiveDismissDidFinish(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) { + dismiss(animated: true) + } + + func interactiveDismissDidCancel(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) { + view.backgroundColor = Theme.darkThemeBackgroundColor + } + + // MARK: - Gestures lazy var pinchZoomGesture: UIPinchGestureRecognizer = { return UIPinchGestureRecognizer(target: self, action: #selector(didPinchZoom(pinchGesture:))) @@ -346,19 +452,17 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate @objc func didTapClose() { - self.delegate?.photoCaptureViewControllerDidCancel(self) + delegate?.photoCaptureViewControllerDidCancel(self) } @objc func didTapSwitchCamera() { - Logger.debug("") switchCamera() } @objc func didDoubleTapToSwitchCamera(tapGesture: UITapGestureRecognizer) { - Logger.debug("") - guard !isRecordingMovie else { + guard !isRecordingVideo else { // - Orientation gets out of sync when switching cameras mid movie. // - Audio gets out of sync when switching cameras mid movie // https://stackoverflow.com/questions/13951182/audio-video-out-of-sync-after-switch-camera @@ -368,9 +472,11 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate } private func switchCamera() { - UIView.animate(withDuration: 0.2) { - let epsilonToForceCounterClockwiseRotation: CGFloat = 0.00001 - self.switchCameraControl.button.transform = self.switchCameraControl.button.transform.rotate(.pi + epsilonToForceCounterClockwiseRotation) + if let switchCameraButton = isIPadUIInRegularMode ? sideBar?.switchCameraButton : bottomBar.switchCameraButton { + UIView.animate(withDuration: 0.2) { + let epsilonToForceCounterClockwiseRotation: CGFloat = 0.00001 + switchCameraButton.transform = switchCameraButton.transform.rotate(.pi + epsilonToForceCounterClockwiseRotation) + } } photoCapture.switchCamera().catch { error in self.showFailureUI(error: error) @@ -379,7 +485,6 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate @objc func didTapFlashMode() { - Logger.debug("") firstly { photoCapture.switchFlashMode() }.done { @@ -389,11 +494,28 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate } } + @objc + func didTapBatchMode() { + guard let delegate = delegate else { + return + } + isInBatchMode = delegate.photoCaptureViewController(self, didRequestSwitchBatchMode: !isInBatchMode) + } + + @objc + func didTapPhotoLibrary() { + delegate?.photoCaptureViewControllerDidRequestPresentPhotoLibrary(self) + } + + @objc + func didTapDoneButton() { + delegate?.photoCaptureViewControllerDidFinish(self) + } + @objc func didPinchZoom(pinchGesture: UIPinchGestureRecognizer) { switch pinchGesture.state { - case .began: fallthrough - case .changed: + case .began, .changed: photoCapture.updateZoom(scaleFromPreviousZoomFactor: pinchGesture.scale) case .ended: photoCapture.completeZoom(scaleFromPreviousZoomFactor: pinchGesture.scale) @@ -404,66 +526,41 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate @objc func didTapFocusExpose(tapGesture: UITapGestureRecognizer) { - let viewLocation = tapGesture.location(in: view) - let devicePoint = previewView.previewLayer.captureDevicePointConverted(fromLayerPoint: viewLocation) - - photoCapture.focus(with: .autoFocus, exposureMode: .autoExpose, at: devicePoint, monitorSubjectAreaChange: true) - - // If the user taps near the capture button, it's more likely a mis-tap than intentional. - // Skip the focus animation in that case, since it looks bad. - let captureButtonOrigin = captureButton.superview!.convert(captureButton.frame.origin, to: view) - if UIDevice.current.isIPad { - guard viewLocation.x < captureButtonOrigin.x else { - Logger.verbose("Skipping animation for right edge on iPad") - - // Finish any outstanding focus animation, otherwise it will remain in an - // uncompleted state. - if let lastUserFocusTapPoint = lastUserFocusTapPoint { - completeFocusAnimation(forFocusPoint: lastUserFocusTapPoint) - } - return - } - } else { - guard viewLocation.y < captureButtonOrigin.y else { - Logger.verbose("Skipping animation for bottom row on iPhone") - - // Finish any outstanding focus animation, otherwise it will remain in an - // uncompleted state. - if let lastUserFocusTapPoint = lastUserFocusTapPoint { - completeFocusAnimation(forFocusPoint: lastUserFocusTapPoint) - } - return - } + guard previewView.bounds.contains(tapGesture.location(in: previewView)) else { + return } + let viewLocation = tapGesture.location(in: previewView) + let devicePoint = previewView.previewLayer.captureDevicePointConverted(fromLayerPoint: viewLocation) + photoCapture.focus(with: .autoFocus, exposureMode: .autoExpose, at: devicePoint, monitorSubjectAreaChange: true) + lastUserFocusTapPoint = devicePoint do { - let convertedPoint = tapToFocusView.superview!.convert(viewLocation, from: view) - positionTapToFocusView(center: convertedPoint) - tapToFocusView.superview?.layoutIfNeeded() + let focusFrameSuperview = tapToFocusView.superview! + positionTapToFocusView(center: tapGesture.location(in: focusFrameSuperview)) + focusFrameSuperview.layoutIfNeeded() startFocusAnimation() } } // MARK: - Focus Animations - var tapToFocusLeftConstraint: NSLayoutConstraint! - var tapToFocusTopConstraint: NSLayoutConstraint! - func positionTapToFocusView(center: CGPoint) { + private var tapToFocusLeftConstraint: NSLayoutConstraint! + private var tapToFocusTopConstraint: NSLayoutConstraint! + private var lastUserFocusTapPoint: CGPoint? + + private func positionTapToFocusView(center: CGPoint) { tapToFocusLeftConstraint.constant = center.x tapToFocusTopConstraint.constant = center.y } - func startFocusAnimation() { + private func startFocusAnimation() { tapToFocusView.stop() tapToFocusView.play(fromProgress: 0.0, toProgress: 0.9) } - var lastUserFocusTapPoint: CGPoint? - func completeFocusAnimation(forFocusPoint focusPoint: CGPoint) { - guard let lastUserFocusTapPoint = lastUserFocusTapPoint else { - return - } + private func completeFocusAnimation(forFocusPoint focusPoint: CGPoint) { + guard let lastUserFocusTapPoint = lastUserFocusTapPoint else { return } guard lastUserFocusTapPoint.within(0.005, of: focusPoint) else { Logger.verbose("focus completed for obsolete focus point. User has refocused.") @@ -475,12 +572,8 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate // MARK: - Orientation - // MARK: - - private func updateIconOrientations(isAnimated: Bool, captureOrientation: AVCaptureVideoOrientation) { - guard !UIDevice.current.isIPad else { - return - } + guard !UIDevice.current.isIPad else { return } Logger.verbose("captureOrientation: \(captureOrientation)") @@ -490,9 +583,9 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate transformFromOrientation = .identity case .portraitUpsideDown: transformFromOrientation = CGAffineTransform(rotationAngle: .pi) - case .landscapeLeft: - transformFromOrientation = CGAffineTransform(rotationAngle: .halfPi) case .landscapeRight: + transformFromOrientation = CGAffineTransform(rotationAngle: .halfPi) + case .landscapeLeft: transformFromOrientation = CGAffineTransform(rotationAngle: -1 * .halfPi) @unknown default: owsFailDebug("unexpected captureOrientation: \(captureOrientation.rawValue)") @@ -502,9 +595,10 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate // Don't "unrotate" the switch camera icon if the front facing camera had been selected. let tranformFromCameraType: CGAffineTransform = photoCapture.desiredPosition == .front ? CGAffineTransform(rotationAngle: -.pi) : .identity + let buttonsToUpdate: [UIView] = [ topBar.batchModeButton, topBar.flashModeButton, bottomBar.photoLibraryButton ] let updateOrientation = { - self.flashModeControl.button.transform = transformFromOrientation - self.switchCameraControl.button.transform = transformFromOrientation.concatenating(tranformFromCameraType) + buttonsToUpdate.forEach { $0.transform = transformFromOrientation } + self.bottomBar.switchCameraButton.transform = transformFromOrientation.concatenating(tranformFromCameraType) } if isAnimated { @@ -514,16 +608,24 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate } } + // MARK: - Photo Capture + var hasCaptureStarted = false private func captureReady() { - self.hasCaptureStarted = true - BenchEventComplete(eventId: "Show-Camera") + self.hasCaptureStarted = true + BenchEventComplete(eventId: "Show-Camera") + if isVisible { + VolumeButtons.shared?.addObserver(observer: photoCapture) + } } private func setupPhotoCapture() { photoCapture.delegate = self - captureButton.delegate = photoCapture + bottomBar.captureControl.delegate = photoCapture + if let sideBar = sideBar { + sideBar.cameraCaptureControl.delegate = photoCapture + } // If the session is already running, we're good to go. guard !photoCapture.session.isRunning else { @@ -570,21 +672,225 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate } private func updateFlashModeControl() { - let imageName: String + let image: UIImage? switch photoCapture.flashMode { case .auto: - imageName = "ic_flash_mode_auto" + image = ButtonImages.flashAuto + case .on: - imageName = "ic_flash_mode_on" + image = ButtonImages.flashOn + case .off: - imageName = "ic_flash_mode_off" + image = ButtonImages.flashOff + @unknown default: owsFailDebug("unexpected photoCapture.flashMode: \(photoCapture.flashMode.rawValue)") - - imageName = "ic_flash_mode_auto" + image = ButtonImages.flashAuto } + topBar.flashModeButton.setImage(image, for: .normal) + if let sideBar = sideBar { + sideBar.flashModeButton.setImage(image, for: .normal) + } + } +} - self.flashModeControl.setImage(imageName: imageName) +private struct ButtonImages { + static let close = UIImage(named: "media-composer-close") + static let switchCamera = UIImage(named: "media-composer-switch-camera-24") + + static let batchModeOn = UIImage(named: "media-composer-create-album-solid-24") + static let batchModeOff = UIImage(named: "media-composer-create-album-outline-24") + + static let flashOn = UIImage(named: "media-composer-flash-filled-24") + static let flashOff = UIImage(named: "media-composer-flash-outline-24") + static let flashAuto = UIImage(named: "media-composer-flash-auto-24") +} + +private class TopBar: UIView { + let closeButton = CameraOverlayButton(image: ButtonImages.close, userInterfaceStyleOverride: .dark) + + private let cameraControlsContainerView: UIStackView + let flashModeButton = CameraOverlayButton(image: ButtonImages.flashAuto, userInterfaceStyleOverride: .dark) + let batchModeButton = CameraOverlayButton(image: ButtonImages.flashAuto, userInterfaceStyleOverride: .dark) + + let recordingTimerView = RecordingTimerView(frame: .zero) + + override init(frame: CGRect) { + cameraControlsContainerView = UIStackView(arrangedSubviews: [ batchModeButton, flashModeButton ]) + + super.init(frame: frame) + + layoutMargins = UIEdgeInsets(hMargin: 8, vMargin: 4) + + addSubview(closeButton) + closeButton.autoPinHeightToSuperviewMargins() + closeButton.autoPinLeadingToSuperviewMargin() + + addSubview(recordingTimerView) + recordingTimerView.autoPinHeightToSuperview(withMargin: 8) + recordingTimerView.autoHCenterInSuperview() + + cameraControlsContainerView.spacing = 16 + addSubview(cameraControlsContainerView) + cameraControlsContainerView.autoPinHeightToSuperviewMargins() + cameraControlsContainerView.autoPinTrailingToSuperviewMargin() + } + + @available(*, unavailable, message: "Use init(frame:) instead") + required init?(coder: NSCoder) { + notImplemented() + } + + // MARK: - Mode + + enum Mode { + case cameraControls, closeButton, videoRecording + } + + var mode: Mode = .cameraControls { + didSet { + switch mode { + case .cameraControls: + closeButton.isHidden = false + cameraControlsContainerView.isHidden = false + recordingTimerView.isHidden = true + + case .closeButton: + closeButton.isHidden = false + cameraControlsContainerView.isHidden = true + recordingTimerView.isHidden = true + + case .videoRecording: + closeButton.isHidden = true + cameraControlsContainerView.isHidden = true + recordingTimerView.isHidden = false + } + } + } +} + +private class BottomBar: UIView { + private var compactHeightLayoutConstraints = [NSLayoutConstraint]() + private var regularHeightLayoutConstraints = [NSLayoutConstraint]() + var isCompactHeightLayout = false { + didSet { + guard oldValue != isCompactHeightLayout else { return } + updateCompactHeightLayoutConstraints() + } + } + + var isRecordingVideo = false { + didSet { + photoLibraryButton.isHidden = isRecordingVideo + switchCameraButton.isHidden = isRecordingVideo + } + } + + let photoLibraryButton = MediaPickerThumbnailButton(frame: .zero) + let switchCameraButton = CameraOverlayButton(image: ButtonImages.switchCamera, userInterfaceStyleOverride: .dark) + let controlButtonsLayoutGuide = UILayoutGuide() // area encompassing Photo Library and Switch Camera buttons. + + let captureControl = CameraCaptureControl(axis: .horizontal) + var shutterButtonLayoutGuide: UILayoutGuide { + captureControl.shutterButtonLayoutGuide + } + + override init(frame: CGRect) { + super.init(frame: frame) + + layoutMargins = UIEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 14) + + addLayoutGuide(controlButtonsLayoutGuide) + addConstraints([ controlButtonsLayoutGuide.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + controlButtonsLayoutGuide.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor) ]) + + photoLibraryButton.translatesAutoresizingMaskIntoConstraints = false + addSubview(photoLibraryButton) + addConstraints([ photoLibraryButton.leadingAnchor.constraint(equalTo: controlButtonsLayoutGuide.leadingAnchor), + photoLibraryButton.centerYAnchor.constraint(equalTo: controlButtonsLayoutGuide.centerYAnchor), + photoLibraryButton.topAnchor.constraint(greaterThanOrEqualTo: controlButtonsLayoutGuide.topAnchor) ]) + + switchCameraButton.translatesAutoresizingMaskIntoConstraints = false + addSubview(switchCameraButton) + addConstraints([ switchCameraButton.trailingAnchor.constraint(equalTo: controlButtonsLayoutGuide.trailingAnchor), + switchCameraButton.topAnchor.constraint(greaterThanOrEqualTo: controlButtonsLayoutGuide.topAnchor), + switchCameraButton.centerYAnchor.constraint(equalTo: controlButtonsLayoutGuide.centerYAnchor) ]) + + captureControl.translatesAutoresizingMaskIntoConstraints = false + addSubview(captureControl) + captureControl.autoPinTopToSuperviewMargin() + captureControl.autoPinTrailingToSuperviewMargin() + addConstraint(captureControl.shutterButtonLayoutGuide.centerXAnchor.constraint(equalTo: centerXAnchor)) + + compactHeightLayoutConstraints.append(contentsOf: [ controlButtonsLayoutGuide.centerYAnchor.constraint(equalTo: captureControl.shutterButtonLayoutGuide.centerYAnchor), + captureControl.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor) ]) + + regularHeightLayoutConstraints.append(contentsOf: [ controlButtonsLayoutGuide.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + captureControl.bottomAnchor.constraint(lessThanOrEqualTo: layoutMarginsGuide.bottomAnchor) ]) + + updateCompactHeightLayoutConstraints() + } + + @available(*, unavailable, message: "Use init(frame:) instead") + required init?(coder: NSCoder) { + notImplemented() + } + + private func updateCompactHeightLayoutConstraints() { + if isCompactHeightLayout { + removeConstraints(regularHeightLayoutConstraints) + addConstraints(compactHeightLayoutConstraints) + } else { + removeConstraints(compactHeightLayoutConstraints) + addConstraints(regularHeightLayoutConstraints) + } + } + +} + +private class SideBar: UIView { + var isRecordingVideo = false { + didSet { + cameraControlsContainerView.isHidden = isRecordingVideo + photoLibraryButton.isHidden = isRecordingVideo + } + } + + private let cameraControlsContainerView: UIStackView + let flashModeButton = CameraOverlayButton(image: ButtonImages.flashAuto, userInterfaceStyleOverride: .dark) + let batchModeButton = CameraOverlayButton(image: ButtonImages.batchModeOff, userInterfaceStyleOverride: .dark) + let switchCameraButton = CameraOverlayButton(image: ButtonImages.switchCamera, userInterfaceStyleOverride: .dark) + + let photoLibraryButton = MediaPickerThumbnailButton(frame: .zero) + + private(set) var cameraCaptureControl = CameraCaptureControl(axis: .vertical) + + override init(frame: CGRect) { + cameraControlsContainerView = UIStackView(arrangedSubviews: [ batchModeButton, flashModeButton, switchCameraButton ]) + + super.init(frame: frame) + + layoutMargins = UIEdgeInsets(margin: 8) + + cameraControlsContainerView.spacing = 16 + cameraControlsContainerView.axis = .vertical + addSubview(cameraControlsContainerView) + cameraControlsContainerView.autoPinWidthToSuperviewMargins() + cameraControlsContainerView.autoPinTopToSuperviewMargin() + + addSubview(cameraCaptureControl) + cameraCaptureControl.autoHCenterInSuperview() + cameraCaptureControl.shutterButtonLayoutGuide.topAnchor.constraint(equalTo: cameraControlsContainerView.bottomAnchor, constant: 36).isActive = true + + addSubview(photoLibraryButton) + photoLibraryButton.autoHCenterInSuperview() + photoLibraryButton.topAnchor.constraint(equalTo: cameraCaptureControl.shutterButtonLayoutGuide.bottomAnchor, constant: 36).isActive = true + photoLibraryButton.bottomAnchor.constraint(lessThanOrEqualTo: layoutMarginsGuide.bottomAnchor).isActive = true + } + + @available(*, unavailable, message: "Use init(frame:) instead") + required init?(coder: NSCoder) { + notImplemented() } } @@ -592,7 +898,7 @@ extension PhotoCaptureViewController: PhotoCaptureDelegate { // MARK: - Photo - func photoCaptureDidStartPhotoCapture(_ photoCapture: PhotoCapture) { + func photoCaptureDidStart(_ photoCapture: PhotoCapture) { let captureFeedbackView = UIView() captureFeedbackView.backgroundColor = .black view.insertSubview(captureFeedbackView, aboveSubview: previewView) @@ -609,15 +915,18 @@ extension PhotoCaptureViewController: PhotoCaptureDelegate { } } - func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessingAttachment attachment: SignalAttachment) { - delegate?.photoCaptureViewController(self, didFinishProcessingAttachment: attachment) + func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessing attachment: SignalAttachment) { + dataSource?.addMedia(attachment: attachment) + + updateDoneButtonAppearance() + + if !isInBatchMode { + delegate?.photoCaptureViewControllerDidFinish(self) + } } - func photoCapture(_ photoCapture: PhotoCapture, processingDidError error: Error) { - isRecordingMovie = false - topBar.recordingTimerView.stopCounting() - updateNavigationItems() - delegate?.photoCaptureViewController(self, isRecordingMovie: isRecordingMovie) + func photoCapture(_ photoCapture: PhotoCapture, didFailProcessing error: Error) { + isRecordingVideo = false if case PhotoCaptureError.invalidVideo = error { // Don't show an error if the user aborts recording before video @@ -628,49 +937,52 @@ extension PhotoCaptureViewController: PhotoCaptureDelegate { } func photoCaptureCanCaptureMoreItems(_ photoCapture: PhotoCapture) -> Bool { - guard let delegate = delegate else { return false } - return delegate.photoCaptureViewControllerCanCaptureMoreItems(self) + return delegate?.photoCaptureViewControllerCanCaptureMoreItems(self) ?? false } func photoCaptureDidTryToCaptureTooMany(_ photoCapture: PhotoCapture) { delegate?.photoCaptureViewControllerDidTryToCaptureTooMany(self) } - // MARK: - Movie + // MARK: - Video - func photoCaptureDidBeginMovie(_ photoCapture: PhotoCapture) { - isRecordingMovie = true - updateNavigationItems() - topBar.recordingTimerView.startCounting() - delegate?.photoCaptureViewController(self, isRecordingMovie: isRecordingMovie) + func photoCaptureWillBeginRecording(_ photoCapture: PhotoCapture) { + isRecordingVideo = true } - func photoCaptureDidCompleteMovie(_ photoCapture: PhotoCapture) { - isRecordingMovie = false - topBar.recordingTimerView.stopCounting() - updateNavigationItems() - delegate?.photoCaptureViewController(self, isRecordingMovie: isRecordingMovie) + func photoCaptureDidBeginRecording(_ photoCapture: PhotoCapture) { + isRecordingVideo = true } - func photoCaptureDidCancelMovie(_ photoCapture: PhotoCapture) { - isRecordingMovie = false - topBar.recordingTimerView.stopCounting() - updateNavigationItems() - delegate?.photoCaptureViewController(self, isRecordingMovie: isRecordingMovie) + func photoCaptureDidFinishRecording(_ photoCapture: PhotoCapture) { + isRecordingVideo = false + } + + func photoCaptureDidCancelRecording(_ photoCapture: PhotoCapture) { + isRecordingVideo = false } // MARK: - - var zoomScaleReferenceHeight: CGFloat? { + var zoomScaleReferenceDistance: CGFloat? { + if isIPadUIInRegularMode { + return view.bounds.width + } return view.bounds.height } func beginCaptureButtonAnimation(_ duration: TimeInterval) { - captureButton.beginRecordingAnimation(duration: duration) + bottomBar.captureControl.setState(.recording, animationDuration: duration) + if let sideBar = sideBar { + sideBar.cameraCaptureControl.setState(.recording, animationDuration: duration) + } } func endCaptureButtonAnimation(_ duration: TimeInterval) { - captureButton.endRecordingAnimation(duration: duration) + bottomBar.captureControl.setState(.initial, animationDuration: duration) + if let sideBar = sideBar { + sideBar.cameraCaptureControl.setState(.initial, animationDuration: duration) + } } func photoCapture(_ photoCapture: PhotoCapture, didChangeOrientation orientation: AVCaptureVideoOrientation) { @@ -680,234 +992,56 @@ extension PhotoCaptureViewController: PhotoCaptureDelegate { } } - func photoCapture(_ photoCapture: PhotoCapture, didCompleteFocusingAtPoint focusPoint: CGPoint) { + func photoCapture(_ photoCapture: PhotoCapture, didCompleteFocusing focusPoint: CGPoint) { completeFocusAnimation(forFocusPoint: focusPoint) } } // MARK: - Views -protocol CaptureButtonDelegate: AnyObject { - // MARK: Photo - func didTapCaptureButton(_ captureButton: CaptureButton) +private class MediaPickerThumbnailButton: UIButton { - // MARK: Video - func didBeginLongPressCaptureButton(_ captureButton: CaptureButton) - func didCompleteLongPressCaptureButton(_ captureButton: CaptureButton) - func didCancelLongPressCaptureButton(_ captureButton: CaptureButton) - func didPressStopCaptureButton(_ captureButton: CaptureButton) + private static let visibleSize = CGSize(square: 36) - var zoomScaleReferenceHeight: CGFloat? { get } - func longPressCaptureButton(_ captureButton: CaptureButton, didUpdateZoomAlpha zoomAlpha: CGFloat) -} + func configure() { + layer.cornerRadius = 10 + layer.borderWidth = 1.5 + layer.borderColor = UIColor.ows_whiteAlpha80.cgColor + clipsToBounds = true -extension CaptureButton: MovieLockViewDelegate { - func videoLockViewDidTapStop(_ videoLockView: MovieLockView) { - assert(movieLockView.isLocked) - movieLockView.unlock(isAnimated: true) - UIView.animate(withDuration: 0.2) { - self.movieLockView.alpha = 0 + let placeholderView = UIVisualEffectView(effect: UIBlurEffect(style: .light)) + insertSubview(placeholderView, at: 0) + placeholderView.autoPinEdgesToSuperviewEdges() + + var authorizationStatus: PHAuthorizationStatus + if #available(iOS 14, *) { + authorizationStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite) + } else { + authorizationStatus = PHPhotoLibrary.authorizationStatus() } - delegate?.didPressStopCaptureButton(self) - } -} + guard authorizationStatus == .authorized else { return } -class CaptureButton: UIView { + // Async Fetch last image + DispatchQueue.global(qos: .userInteractive).async { + let fetchOptions = PHFetchOptions() + fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] + fetchOptions.fetchLimit = 1 - let innerButton = CircleView() - let movieLockView = MovieLockView(swipeDirectionToLock: UIDevice.current.isIPad ? .leading : .trailing) - - var longPressGesture: UILongPressGestureRecognizer! - let longPressDuration = 0.5 - - let zoomIndicator = CircleView() - - weak var delegate: CaptureButtonDelegate? - - let defaultDiameter: CGFloat = min(ScaleFromIPhone5To7Plus(60, 80), 80) - static let recordingDiameter: CGFloat = min(ScaleFromIPhone5To7Plus(68, 120), 120) - var innerButtonSizeConstraints: [NSLayoutConstraint]! - var zoomIndicatorSizeConstraints: [NSLayoutConstraint]! - - override init(frame: CGRect) { - super.init(frame: frame) - - // The long press handles both the tap and the hold interaction, as well as the animation - // the presents as the user begins to hold (and the button begins to grow prior to recording) - longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(didLongPress)) - longPressGesture.minimumPressDuration = 0 - innerButton.addGestureRecognizer(longPressGesture) - - addSubview(innerButton) - innerButtonSizeConstraints = autoSetDimensions(to: CGSize(square: defaultDiameter)) - innerButton.backgroundColor = UIColor.ows_white.withAlphaComponent(0.33) - innerButton.layer.shadowOffset = .zero - innerButton.layer.shadowOpacity = 0.33 - innerButton.layer.shadowRadius = 2 - innerButton.autoPinEdgesToSuperviewEdges() - - addSubview(zoomIndicator) - zoomIndicatorSizeConstraints = zoomIndicator.autoSetDimensions(to: CGSize(square: defaultDiameter)) - zoomIndicator.isUserInteractionEnabled = false - zoomIndicator.layer.borderColor = UIColor.ows_white.cgColor - zoomIndicator.layer.borderWidth = 1.5 - zoomIndicator.autoAlignAxis(.horizontal, toSameAxisOf: innerButton) - zoomIndicator.autoAlignAxis(.vertical, toSameAxisOf: innerButton) - - addSubview(movieLockView) - movieLockView.autoSetDimension(.height, toSize: 50) - movieLockView.stopButton.autoAlignAxis(.horizontal, toSameAxisOf: self) - movieLockView.stopButton.autoAlignAxis(.vertical, toSameAxisOf: self) - movieLockView.alpha = 0 - movieLockView.delegate = self - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func beginRecordingAnimation(duration: TimeInterval, delay: TimeInterval = 0) { - UIView.animate( - withDuration: duration, - delay: delay, - options: [.beginFromCurrentState, .curveLinear], - animations: { - self.innerButtonSizeConstraints.forEach { $0.constant = type(of: self).recordingDiameter } - self.zoomIndicatorSizeConstraints.forEach { $0.constant = type(of: self).recordingDiameter } - self.superview?.layoutIfNeeded() - }, - completion: nil - ) - } - - func endRecordingAnimation(duration: TimeInterval, delay: TimeInterval = 0) { - UIView.animate( - withDuration: duration, - delay: delay, - options: [.beginFromCurrentState, .curveEaseIn], - animations: { - self.innerButtonSizeConstraints.forEach { $0.constant = self.defaultDiameter } - self.zoomIndicatorSizeConstraints.forEach { $0.constant = self.defaultDiameter } - self.superview?.layoutIfNeeded() - }, - completion: nil - ) - } - - // MARK: - Gestures - - var initialTouchLocation: CGPoint? - var touchTimer: Timer? - var isLongPressing = false - - @objc - func didLongPress(_ gesture: UILongPressGestureRecognizer) { - guard let gestureView = gesture.view else { - owsFailDebug("gestureView was unexpectedly nil") - return + let fetchResult = PHAsset.fetchAssets(with: PHAssetMediaType.image, options: fetchOptions) + if fetchResult.count > 0, let asset = fetchResult.firstObject { + let targetImageSize = MediaPickerThumbnailButton.visibleSize + PHImageManager.default().requestImage(for: asset, targetSize: targetImageSize, contentMode: .aspectFill, options: nil) { (image, _) in + DispatchQueue.main.async { + self.setImage(image, for: .normal) + placeholderView.alpha = 0 + } + } + } } + } - switch gesture.state { - case .possible: break - case .began: - guard !movieLockView.isLocked else { - return - } - - initialTouchLocation = gesture.location(in: gesture.view) - beginRecordingAnimation(duration: 0.4, delay: 0.1) - - isLongPressing = false - - touchTimer?.invalidate() - touchTimer = WeakTimer.scheduledTimer( - timeInterval: longPressDuration, - target: self, - userInfo: nil, - repeats: false - ) { [weak self] _ in - guard let `self` = self else { return } - self.isLongPressing = true - - self.movieLockView.unlock(isAnimated: false) - UIView.animate(withDuration: 0.2) { - self.movieLockView.alpha = 1 - } - self.delegate?.didBeginLongPressCaptureButton(self) - } - case .changed: - guard isLongPressing else { break } - - guard let referenceHeight = delegate?.zoomScaleReferenceHeight else { - owsFailDebug("referenceHeight was unexpectedly nil") - return - } - - guard referenceHeight > 0 else { - owsFailDebug("referenceHeight was unexpectedly <= 0") - return - } - - guard let initialTouchLocation = initialTouchLocation else { - owsFailDebug("initialTouchLocation was unexpectedly nil") - return - } - - let currentLocation = gesture.location(in: gestureView) - - // Zoom - let minDistanceBeforeActivatingZoom: CGFloat = 30 - let yDistance = initialTouchLocation.y - currentLocation.y - minDistanceBeforeActivatingZoom - let distanceForFullZoom = referenceHeight / 4 - let yRatio = yDistance / distanceForFullZoom - let yAlpha = yRatio.clamp(0, 1) - - let zoomIndicatorDiameter = CGFloatLerp(type(of: self).recordingDiameter, 3, yAlpha) - self.zoomIndicatorSizeConstraints.forEach { $0.constant = zoomIndicatorDiameter } - zoomIndicator.superview?.layoutIfNeeded() - - delegate?.longPressCaptureButton(self, didUpdateZoomAlpha: yAlpha) - - // Lock - - guard !movieLockView.isLocked else { - return - } - let xOffset = currentLocation.x - initialTouchLocation.x - movieLockView.update(xOffset: xOffset) - case .ended: - endRecordingAnimation(duration: 0.2) - touchTimer?.invalidate() - touchTimer = nil - - guard !movieLockView.isLocked else { - return - } - - if isLongPressing { - UIView.animate(withDuration: 0.2) { - self.movieLockView.alpha = 0 - } - delegate?.didCompleteLongPressCaptureButton(self) - } else { - delegate?.didTapCaptureButton(self) - } - case .cancelled, .failed: - endRecordingAnimation(duration: 0.2) - - if isLongPressing { - self.movieLockView.unlock(isAnimated: true) - UIView.animate(withDuration: 0.2) { - self.movieLockView.alpha = 0 - } - delegate?.didCancelLongPressCaptureButton(self) - } - - touchTimer?.invalidate() - touchTimer = nil - @unknown default: - owsFailDebug("unexpected gesture state: \(gesture.state.rawValue)") - } + override var intrinsicContentSize: CGSize { + return Self.visibleSize } } @@ -921,19 +1055,13 @@ class CapturePreviewView: UIView { } } - override var contentMode: UIView.ContentMode { - set { - switch newValue { - case .scaleAspectFill: - previewLayer.videoGravity = .resizeAspectFill - case .scaleAspectFit: - previewLayer.videoGravity = .resizeAspect - case .scaleToFill: - previewLayer.videoGravity = .resize - default: - owsFailDebug("Unexpected contentMode") - } + override var frame: CGRect { + didSet { + previewLayer.frame = bounds } + } + + override var contentMode: UIView.ContentMode { get { switch previewLayer.videoGravity { case .resizeAspectFill: @@ -947,6 +1075,18 @@ class CapturePreviewView: UIView { return .scaleToFill } } + set { + switch newValue { + case .scaleAspectFill: + previewLayer.videoGravity = .resizeAspectFill + case .scaleAspectFit: + previewLayer.videoGravity = .resizeAspect + case .scaleToFill: + previewLayer.videoGravity = .resize + default: + owsFailDebug("Unexpected contentMode") + } + } } init(session: AVCaptureSession) { @@ -961,63 +1101,58 @@ class CapturePreviewView: UIView { layer.addSublayer(previewLayer) } - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") + @available(*, unavailable, message: "Use init(session:) instead") + required init?(coder: NSCoder) { + notImplemented() } } -class RecordingTimerView: UIView { - - let stackViewSpacing: CGFloat = 4 +private class RecordingTimerView: PillView { override init(frame: CGRect) { super.init(frame: frame) + layoutMargins = UIEdgeInsets(hMargin: 16, vMargin: 0) + + let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) + addSubview(backgroundView) + backgroundView.autoPinEdgesToSuperviewEdges() + let stackView = UIStackView(arrangedSubviews: [icon, label]) stackView.axis = .horizontal stackView.alignment = .center - stackView.spacing = stackViewSpacing - + stackView.spacing = 5 addSubview(stackView) stackView.autoPinEdgesToSuperviewMargins() updateView() } - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") + @available(*, unavailable, message: "Use init(frame:) instead") + required init?(coder: NSCoder) { + notImplemented() } // MARK: - Subviews - private lazy var label: UILabel = { + private let label: UILabel = { let label = UILabel() label.font = UIFont.ows_monospacedDigitFont(withSize: 20) label.textAlignment = .center label.textColor = UIColor.white - label.layer.shadowOffset = CGSize.zero - label.layer.shadowOpacity = 0.35 - label.layer.shadowRadius = 4 - return label }() - static let iconWidth: CGFloat = 6 - private let icon: UIView = { let icon = CircleView() - icon.layer.shadowOffset = CGSize.zero - icon.layer.shadowOpacity = 0.35 - icon.layer.shadowRadius = 4 - icon.backgroundColor = .red - icon.autoSetDimensions(to: CGSize(square: iconWidth)) + icon.autoSetDimensions(to: CGSize(square: 6)) icon.alpha = 0 - return icon }() // MARK: - + var recordingStartTime: TimeInterval? func startCounting() { @@ -1068,164 +1203,16 @@ class RecordingTimerView: UIView { } } -// MARK: Movie Lock +private extension UIView { -protocol MovieLockViewDelegate: AnyObject { - func videoLockViewDidTapStop(_ videoLockView: MovieLockView) -} - -@objc -public class MovieLockView: UIView { - - weak var delegate: MovieLockViewDelegate? - - public enum SwipeDirection { - case trailing - case leading - } - - public let swipeDirectionToLock: SwipeDirection - - public init(swipeDirectionToLock: SwipeDirection) { - self.swipeDirectionToLock = swipeDirectionToLock - super.init(frame: .zero) - - addSubview(stopButton) - stopButton.autoVCenterInSuperview() - stopButton.alpha = 0 - - addSubview(highlightView) - highlightView.autoVCenterInSuperview() - highlightView.alpha = 0 - - addSubview(lockIconView) - lockIconView.autoVCenterInSuperview() - - let trailingView: UIView - let leadingView: UIView - switch swipeDirectionToLock { - case .trailing: - trailingView = lockIconView - leadingView = stopButton - highlightEdgeConstraint = highlightView.autoPinEdge(toSuperviewEdge: .leading) - case .leading: - trailingView = stopButton - leadingView = lockIconView - highlightEdgeConstraint = highlightView.autoPinEdge(toSuperviewEdge: .trailing) - } - - trailingView.centerXAnchor.constraint(equalTo: trailingAnchor, - constant: -highlightViewWidth/2).isActive = true - leadingView.centerXAnchor.constraint(equalTo: leadingAnchor, - constant: highlightViewWidth/2).isActive = true - - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - public func update(xOffset: CGFloat) { - let effectiveDistance: CGFloat - let distanceToLock: CGFloat - let highlightOffset: CGFloat - switch swipeDirectionToLock { - case .trailing: - let minDistanceBeforeActivatingLockSlider: CGFloat = 30 - effectiveDistance = xOffset - minDistanceBeforeActivatingLockSlider - distanceToLock = frame.width - highlightView.frame.width - highlightOffset = effectiveDistance.clamp(0, distanceToLock) - case .leading: - // On iPad, the gesture already feels right, without applying the additional - // minDistanceBeforeActivatingLockSlider padding. - effectiveDistance = xOffset - distanceToLock = -1 * (frame.width - highlightView.frame.width) - highlightOffset = effectiveDistance.clamp(distanceToLock, 0) - } - highlightEdgeConstraint.constant = highlightOffset - - let alpha = (effectiveDistance/distanceToLock).clamp(0, 1) - highlightView.alpha = alpha - - if alpha == 1.0 { - lock(isAnimated: true) - } - Logger.verbose("xOffset: \(xOffset), effectiveDistance: \(effectiveDistance), distanceToLock: \(distanceToLock), highlightOffset: \(highlightOffset), alpha: \(alpha)") - } - - // MARK: - - - private(set) var isLocked = false - - public func unlock(isAnimated: Bool) { - Logger.debug("") - guard isLocked else { - Logger.debug("ignoring redundant request") - return - } - Logger.debug("unlocking") - - isLocked = false - let changes = { - self.lockIconView.tintColor = .white - self.stopButton.alpha = 0 - self.highlightView.alpha = 0 - } - - if isAnimated { - UIView.animate(withDuration: 0.2, animations: changes) - } else { - changes() - } - } - - private func lock(isAnimated: Bool) { - guard !isLocked else { - Logger.debug("ignoring redundant request") - return - } - Logger.debug("locking") - - isLocked = true - let changes = { - self.lockIconView.tintColor = .black - self.stopButton.alpha = 1.0 - } - - if isAnimated { - UIView.animate(withDuration: 0.2, animations: changes) - } else { - changes() - } - } - - // MARK: - Subviews - - let lockIconWidth: CGFloat = 24 - private lazy var lockIconView: UIImageView = { - let imageView = UIImageView.withTemplateImage(#imageLiteral(resourceName: "ic_lock_outline"), tintColor: .white) - imageView.autoSetDimensions(to: CGSize(square: lockIconWidth)) - return imageView - }() - - let highlightViewWidth = SendMediaNavigationController.bottomButtonWidth - private var highlightEdgeConstraint: NSLayoutConstraint! - private lazy var highlightView: UIView = { - let view = CircleView(diameter: highlightViewWidth) - view.backgroundColor = .white - return view - }() - - let stopButtonWidth: CGFloat = 30 - public lazy var stopButton: UIButton = { - let view = OWSButton { [weak self] in - guard let self = self else { return } - self.delegate?.videoLockViewDidTapStop(self) - } - - view.backgroundColor = .white - view.autoSetDimensions(to: CGSize(square: stopButtonWidth)) - - return view - }() + func embeddedInContainerView(layoutMargins: UIEdgeInsets = .zero) -> UIView { + var containerViewFrame = bounds + containerViewFrame.width += layoutMargins.leading + layoutMargins.trailing + containerViewFrame.height += layoutMargins.top + layoutMargins.bottom + let containerView = UIView(frame: containerViewFrame) + containerView.layoutMargins = layoutMargins + containerView.addSubview(self) + autoPinEdgesToSuperviewMargins() + return containerView + } } diff --git a/Signal/src/ViewControllers/Photos/PhotoCollectionPickerController.swift b/Signal/src/ViewControllers/Photos/PhotoCollectionPickerController.swift index 5e9e998928..8b0729d466 100644 --- a/Signal/src/ViewControllers/Photos/PhotoCollectionPickerController.swift +++ b/Signal/src/ViewControllers/Photos/PhotoCollectionPickerController.swift @@ -62,7 +62,11 @@ class PhotoCollectionPickerController: OWSTableViewController, PhotoLibraryDeleg self.contents = contents } - private let numberFormatter: NumberFormatter = NumberFormatter() + private lazy var numberFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter + }() private func buildTableCell(collection: PhotoCollection) -> UITableViewCell { let cell = OWSTableItem.newCell() diff --git a/Signal/src/ViewControllers/Photos/SendMediaBottomButton.swift b/Signal/src/ViewControllers/Photos/SendMediaBottomButton.swift deleted file mode 100644 index 8641a39f2d..0000000000 --- a/Signal/src/ViewControllers/Photos/SendMediaBottomButton.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// Copyright (c) 2020 Open Whisper Systems. All rights reserved. -// - -import UIKit - -class SendMediaBottomButton: UIView { - let button: OWSButton - let blurView: UIVisualEffectView - - init(imageName: String, tintColor: UIColor, diameter: CGFloat, block: @escaping () -> Void) { - self.button = OWSButton(imageName: imageName, tintColor: tintColor, block: block) - button.imageEdgeInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) - - let blurEffect = UIBlurEffect(style: .dark) - self.blurView = UIVisualEffectView(effect: blurEffect) - - super.init(frame: .zero) - - layer.cornerRadius = diameter / 2 - blurView.layer.cornerRadius = diameter / 2 - blurView.clipsToBounds = true - - addSubview(blurView) - blurView.autoPinEdgesToSuperviewEdges() - - let vibrancyEffect = UIVibrancyEffect(blurEffect: blurEffect) - let vibrancyView = UIVisualEffectView(effect: vibrancyEffect) - blurView.contentView.addSubview(vibrancyView) - vibrancyView.autoPinEdgesToSuperviewEdges() - - addSubview(button) - button.autoSetDimensions(to: CGSize(square: diameter)) - button.autoPinEdgesToSuperviewEdges() - updateViewState() - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - var isSelected: Bool = false { - didSet { - updateViewState() - } - } - - var isBeingPresentedOverPhotoCapture: Bool = false { - didSet { - updateViewState() - } - } - - private enum Mode { - case selected, unselectedOverMediaLibrary, unselectedOverPhotoCapture - } - - private var mode: Mode { - if isSelected { - return .selected - } - - if isBeingPresentedOverPhotoCapture { - return .unselectedOverPhotoCapture - } - - return .unselectedOverMediaLibrary - } - - func updateViewState() { - switch mode { - case .selected: - button.tintColor = .ows_black - blurView.isHidden = true - backgroundColor = .ows_white - setShadow() - case .unselectedOverMediaLibrary: - button.tintColor = .ows_white - blurView.isHidden = false - backgroundColor = .clear - layer.shadowRadius = 0 - case .unselectedOverPhotoCapture: - button.tintColor = .ows_white - blurView.isHidden = true - backgroundColor = .clear - setShadow() - } - } -} diff --git a/Signal/src/ViewControllers/Photos/SendMediaNavigationController.swift b/Signal/src/ViewControllers/Photos/SendMediaNavigationController.swift index e476c8bd18..30129c6d2e 100644 --- a/Signal/src/ViewControllers/Photos/SendMediaNavigationController.swift +++ b/Signal/src/ViewControllers/Photos/SendMediaNavigationController.swift @@ -1,9 +1,10 @@ // -// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// Copyright (c) 2022 Open Whisper Systems. All rights reserved. // import Foundation import Photos +import SignalUI @objc protocol SendMediaNavDelegate: AnyObject { @@ -38,23 +39,8 @@ class CameraFirstCaptureNavigationController: SendMediaNavigationController { } } -public let fixedBottomSafeAreaInset: CGFloat = 20 -public let fixedHorizontalMargin: CGFloat = 16 - @objc class SendMediaNavigationController: OWSNavigationController { - static var bottomButtonsCenterOffset: CGFloat { - if UIDevice.current.hasIPhoneXNotch { - // we pin to a constant rather than margin, because on notched devices the - // safeAreaInsets/margins change as the device rotates *EVEN THOUGH* the interface - // is locked to portrait. - return -1 * (CaptureButton.recordingDiameter / 2 + 4) - fixedBottomSafeAreaInset - } else { - return -1 * (CaptureButton.recordingDiameter / 2 + 4) - } - } - - static var trailingButtonsOffset: CGFloat = -28 var attachmentCount: Int { return attachmentDraftCollection.count @@ -66,69 +52,26 @@ class SendMediaNavigationController: OWSNavigationController { guard !CurrentAppContext().hasActiveCall else { return false } - return true } + override var preferredStatusBarStyle: UIStatusBarStyle { + if topViewController is ConversationPickerViewController { + return .default + } + return .lightContent + } + override func viewDidLoad() { super.viewDidLoad() self.delegate = self - - let bottomButtonsCenterOffset = SendMediaNavigationController.bottomButtonsCenterOffset - - view.addSubview(batchModeButton) - batchModeButton.setCompressionResistanceHigh() - - view.addSubview(doneButton) - doneButton.setCompressionResistanceHigh() - - view.addSubview(cameraModeButton) - cameraModeButton.setCompressionResistanceHigh() - - view.addSubview(mediaLibraryModeButton) - mediaLibraryModeButton.setCompressionResistanceHigh() - - if UIDevice.current.isIPad { - let buttonSpacing: CGFloat = 28 - // `doneButton` is our widest button, so we position it relative to the superview - // margin, and position other buttons relative to `doneButton`. This ensures - // `donebutton` has a good distance from the edge *and* that all the buttons in the - // cluster are centered WRT eachother. - doneButton.autoPinEdge(toSuperviewMargin: .trailing) - doneButton.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor, constant: -buttonSpacing).isActive = true - - batchModeButton.autoAlignAxis(.vertical, toSameAxisOf: doneButton) - batchModeButton.autoAlignAxis(.horizontal, toSameAxisOf: doneButton) - - cameraModeButton.autoAlignAxis(.vertical, toSameAxisOf: doneButton) - cameraModeButton.autoPinEdge(.bottom, to: .top, of: doneButton, withOffset: -buttonSpacing) - - mediaLibraryModeButton.autoAlignAxis(.vertical, toSameAxisOf: cameraModeButton) - mediaLibraryModeButton.autoAlignAxis(.horizontal, toSameAxisOf: cameraModeButton) - } else { - // we pin to edges rather than margin, because on notched devices the safeAreaInsets/margins change - // as the device rotates *EVEN THOUGH* the interface is locked to portrait. - - batchModeButton.centerYAnchor.constraint(equalTo: view.bottomAnchor, constant: bottomButtonsCenterOffset).isActive = true - batchModeButton.autoPinEdge(toSuperviewEdge: .trailing, withInset: 16) - - doneButton.centerYAnchor.constraint(equalTo: view.bottomAnchor, constant: bottomButtonsCenterOffset).isActive = true - doneButton.autoPinEdge(toSuperviewEdge: .trailing, withInset: 16) - - cameraModeButton.centerYAnchor.constraint(equalTo: view.bottomAnchor, constant: bottomButtonsCenterOffset).isActive = true - cameraModeButton.autoPinEdge(toSuperviewEdge: .leading, withInset: 16) - - mediaLibraryModeButton.centerYAnchor.constraint(equalTo: view.bottomAnchor, constant: bottomButtonsCenterOffset).isActive = true - mediaLibraryModeButton.autoPinEdge(toSuperviewEdge: .leading, withInset: 16) - } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) DispatchQueue.main.async { - // pre-layout views for snappier response should the user - // decide to switch + // Pre-layout views for snappier response should the user decide to switch. if PHPhotoLibrary.authorizationStatus() == .authorized { self.mediaLibraryViewController.view.layoutIfNeeded() @@ -184,96 +127,6 @@ class SendMediaNavigationController: OWSNavigationController { return navController } - private var isForcingBatchSelectInMediaLibrary = true - - private var isShowingMediaLibrary = false - private var isRecordingMovie = false - - var isInBatchSelectMode: Bool { - get { - if isForcingBatchSelectInMediaLibrary && isShowingMediaLibrary { - return true - } - return self.batchModeButton.isSelected - } - - set { - let didChange = newValue != isInBatchSelectMode - self.batchModeButton.isSelected = newValue - - if didChange { - mediaLibraryViewController.batchSelectModeDidChange() - guard let topViewController = viewControllers.last else { - return - } - updateViewState(topViewController: topViewController, animated: false) - } - } - } - - func updateViewState(topViewController: UIViewController, animated: Bool) { - let changes: () -> Void - switch topViewController { - case is AttachmentApprovalViewController: - changes = { - self.isShowingMediaLibrary = false - self.batchModeButton.alpha = 0 - self.doneButton.alpha = 0 - self.cameraModeButton.alpha = 0 - self.mediaLibraryModeButton.alpha = 0 - } - case let mediaLibraryView as ImagePickerGridController: - changes = { - self.isShowingMediaLibrary = true - let showDoneButton = self.isInBatchSelectMode && self.attachmentCount > 0 - self.doneButton.alpha = showDoneButton ? 1 : 0 - - self.batchModeButton.alpha = showDoneButton || self.isForcingBatchSelectInMediaLibrary ? 0 : 1 - self.batchModeButton.isBeingPresentedOverPhotoCapture = false - - self.cameraModeButton.alpha = 1 - self.cameraModeButton.isBeingPresentedOverPhotoCapture = false - - self.mediaLibraryModeButton.alpha = 0 - self.mediaLibraryModeButton.isBeingPresentedOverPhotoCapture = false - - mediaLibraryView.applyBatchSelectMode() - } - case is PhotoCaptureViewController: - changes = { - self.isShowingMediaLibrary = false - let showDoneButton = self.isInBatchSelectMode && self.attachmentCount > 0 - self.doneButton.alpha = !showDoneButton || self.isRecordingMovie ? 0 : 1 - - self.batchModeButton.alpha = showDoneButton || self.isRecordingMovie ? 0 : 1 - self.batchModeButton.isBeingPresentedOverPhotoCapture = true - - self.cameraModeButton.alpha = 0 - self.cameraModeButton.isBeingPresentedOverPhotoCapture = true - - self.mediaLibraryModeButton.alpha = self.isRecordingMovie ? 0 : 1 - self.mediaLibraryModeButton.isBeingPresentedOverPhotoCapture = true - } - case is ConversationPickerViewController: - changes = { - self.doneButton.alpha = 0 - self.batchModeButton.alpha = 0 - self.cameraModeButton.alpha = 0 - self.mediaLibraryModeButton.alpha = 0 - } - default: - owsFailDebug("unexpected topViewController: \(topViewController)") - changes = { } - } - - if animated { - UIView.animate(withDuration: 0.3, animations: changes) - } else { - changes() - } - doneButton.updateCount() - } - func fadeTo(viewControllers: [UIViewController], duration: CFTimeInterval) { AssertIsOnMainThread() @@ -284,63 +137,6 @@ class SendMediaNavigationController: OWSNavigationController { setViewControllers(viewControllers, animated: false) } - // MARK: - Events - - private func didTapBatchModeButton() { - isInBatchSelectMode = !isInBatchSelectMode - owsAssertDebug(isInBatchSelectMode || attachmentCount <= 1) - } - - private func didTapCameraModeButton() { - self.ows_askForCameraPermissions { isGranted in - guard isGranted else { return } - - BenchEventStart(title: "Show-Camera", eventId: "Show-Camera") - self.fadeTo(viewControllers: [self.captureViewController], duration: 0.08) - } - } - - private func didTapMediaLibraryModeButton() { - self.ows_askForMediaLibraryPermissions { isGranted in - guard isGranted else { return } - - BenchEventStart(title: "Show-Media-Library", eventId: "Show-Media-Library") - self.fadeTo(viewControllers: [self.mediaLibraryViewController], duration: 0.08) - } - } - - // MARK: Views - public static let bottomButtonWidth: CGFloat = 44 - - private lazy var doneButton: DoneButton = { - let button = DoneButton() - button.delegate = self - button.setShadow() - - return button - }() - - private lazy var batchModeButton: SendMediaBottomButton = { - return SendMediaBottomButton(imageName: "create-album-filled-28", - tintColor: .ows_white, - diameter: type(of: self).bottomButtonWidth, - block: { [weak self] in self?.didTapBatchModeButton() }) - }() - - private lazy var cameraModeButton: SendMediaBottomButton = { - return SendMediaBottomButton(imageName: "camera-outline-28", - tintColor: .ows_white, - diameter: type(of: self).bottomButtonWidth, - block: { [weak self] in self?.didTapCameraModeButton() }) - }() - - private lazy var mediaLibraryModeButton: SendMediaBottomButton = { - return SendMediaBottomButton(imageName: "photo-outline-28", - tintColor: .ows_white, - diameter: type(of: self).bottomButtonWidth, - block: { [weak self] in self?.didTapMediaLibraryModeButton() }) - }() - // MARK: State private var attachmentDraftCollection: AttachmentDraftCollection = .empty @@ -352,24 +148,22 @@ class SendMediaNavigationController: OWSNavigationController { // MARK: Child VC's fileprivate lazy var captureViewController: PhotoCaptureViewController = { - let vc = PhotoCaptureViewController() - vc.delegate = self - - return vc + let viewController = PhotoCaptureViewController() + viewController.delegate = self + viewController.dataSource = self + return viewController }() private lazy var mediaLibraryViewController: ImagePickerGridController = { - let vc = ImagePickerGridController() - vc.delegate = self - - return vc + let viewController = ImagePickerGridController() + viewController.delegate = self + viewController.dataSource = self + return viewController }() - private func pushApprovalViewController( - attachmentApprovalItems: [AttachmentApprovalItem], - options: AttachmentApprovalViewControllerOptions = .canAddMore, - animated: Bool - ) { + private func pushApprovalViewController(attachmentApprovalItems: [AttachmentApprovalItem], + options: AttachmentApprovalViewControllerOptions = .canAddMore, + animated: Bool) { guard let sendMediaNavDelegate = self.sendMediaNavDelegate else { owsFailDebug("sendMediaNavDelegate was unexpectedly nil") return @@ -394,51 +188,35 @@ class SendMediaNavigationController: OWSNavigationController { } else { let alert = ActionSheetController(title: nil, message: nil) - let confirmAbandonText = NSLocalizedString("SEND_MEDIA_CONFIRM_ABANDON_ALBUM", comment: "alert action, confirming the user wants to exit the media flow and abandon any photos they've taken") + let confirmAbandonText = NSLocalizedString("SEND_MEDIA_CONFIRM_ABANDON_ALBUM", + comment: "alert action, confirming the user wants to exit the media flow and abandon any photos they've taken") let confirmAbandonAction = ActionSheetAction(title: confirmAbandonText, - style: .destructive, - handler: { [weak self] _ in - guard let self = self else { return } - self.sendMediaNavDelegate?.sendMediaNavDidCancel(self) + style: .destructive, + handler: { [weak self] _ in + guard let self = self else { return } + self.sendMediaNavDelegate?.sendMediaNavDidCancel(self) }) alert.addAction(confirmAbandonAction) let dontAbandonAction = ActionSheetAction(title: dontAbandonText, - style: .default, - handler: { _ in }) + style: .default, + handler: { _ in }) alert.addAction(dontAbandonAction) self.presentActionSheet(alert) } } + } extension SendMediaNavigationController: UINavigationControllerDelegate { + func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { updateNavbarTheme(for: viewController, animated: animated) - - switch viewController { - case is PhotoCaptureViewController: - if attachmentDraftCollection.count == 1 && !isInBatchSelectMode { - // User is navigating "back" to the previous view, indicating - // they want to discard the previously captured item - discardDraft() - } - case is ImagePickerGridController: - if attachmentDraftCollection.count == 1 && !isInBatchSelectMode { - isInBatchSelectMode = true - mediaLibraryViewController.reloadData() - } - default: - break - } - - updateViewState(topViewController: viewController, animated: false) } // In case back navigation was canceled, we re-apply whatever is showing. func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { updateNavbarTheme(for: viewController, animated: animated) - updateViewState(topViewController: viewController, animated: false) } func navigationControllerSupportedInterfaceOrientations(_ navigationController: UINavigationController) -> UIInterfaceOrientationMask { @@ -467,7 +245,7 @@ extension SendMediaNavigationController: UINavigationControllerDelegate { case is AttachmentApprovalViewController: showNavbar(.alwaysDarkAndClear) case is ImagePickerGridController: - showNavbar(.alwaysDark) + showNavbar(.alwaysDarkAndClear) case is ConversationPickerViewController: showNavbar(.default) default: @@ -479,34 +257,23 @@ extension SendMediaNavigationController: UINavigationControllerDelegate { // MARK: - Too Many func showTooManySelectedToast() { - Logger.info("") - let toastFormat = NSLocalizedString("IMAGE_PICKER_CAN_SELECT_NO_MORE_TOAST_FORMAT", comment: "Momentarily shown to the user when attempting to select more images than is allowed. Embeds {{max number of items}} that can be shared.") let toastText = String(format: toastFormat, NSNumber(value: SignalAttachment.maxAttachmentsAllowed)) - let toastController = ToastController(text: toastText) - - let kToastInset: CGFloat = 10 - let bottomInset = kToastInset + view.layoutMargins.bottom - - toastController.presentToastView(fromBottomOfView: view, inset: bottomInset) + toastController.presentToastView(fromBottomOfView: view, inset: view.layoutMargins.bottom + 10) } } extension SendMediaNavigationController: PhotoCaptureViewControllerDelegate { - func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: SignalAttachment) { - guard let sendMediaNavDelegate = self.sendMediaNavDelegate else { return } - let cameraCaptureAttachment = CameraCaptureAttachment(signalAttachment: attachment, canSave: sendMediaNavDelegate.sendMediaNavCanSaveAttachments) - attachmentDraftCollection.append(.camera(attachment: cameraCaptureAttachment)) - if isInBatchSelectMode { - updateViewState(topViewController: photoCaptureViewController, animated: false) - } else { - pushApprovalViewController(attachmentApprovalItems: [cameraCaptureAttachment.attachmentApprovalItem], - animated: true) + func photoCaptureViewControllerDidFinish(_ photoCaptureViewController: PhotoCaptureViewController) { + guard attachmentDraftCollection.count > 0 else { + owsFailDebug("No camera attachments found") + return } + showApprovalAfterProcessingAnyMediaLibrarySelections() } func photoCaptureViewControllerDidCancel(_ photoCaptureViewController: PhotoCaptureViewController) { @@ -514,6 +281,13 @@ extension SendMediaNavigationController: PhotoCaptureViewControllerDelegate { didRequestExit(dontAbandonText: dontAbandonText) } + func photoCaptureViewControllerViewWillAppear(_ photoCaptureViewController: PhotoCaptureViewController) { + if !photoCaptureViewController.isInBatchMode, attachmentCount == 1, case .camera(_) = attachmentDraftCollection.attachmentDrafts.last { + // User is navigating back to the camera screen, indicating they want to discard the previously captured item. + discardDraft() + } + } + func photoCaptureViewControllerDidTryToCaptureTooMany(_ photoCaptureViewController: PhotoCaptureViewController) { showTooManySelectedToast() } @@ -530,19 +304,62 @@ extension SendMediaNavigationController: PhotoCaptureViewControllerDelegate { owsAssertDebug(attachmentDraftCollection.attachmentDrafts.count == 0) } - func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, isRecordingMovie: Bool) { - self.isRecordingMovie = isRecordingMovie - updateViewState(topViewController: photoCaptureViewController, animated: true) + func photoCaptureViewControllerDidRequestPresentPhotoLibrary(_ photoCaptureViewController: PhotoCaptureViewController) { + self.ows_askForMediaLibraryPermissions { isGranted in + guard isGranted else { return } + + BenchEventStart(title: "Show-Media-Library", eventId: "Show-Media-Library") + let presentedViewController = OWSNavigationController(rootViewController: self.mediaLibraryViewController) + if let owsNavBar = presentedViewController.navigationBar as? OWSNavigationBar { + owsNavBar.switchToStyle(.alwaysDarkAndClear) + } + self.presentFullScreen(presentedViewController, animated: true) + } + } + + func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didRequestSwitchBatchMode batchMode: Bool) -> Bool { + if batchMode { + // Always can be enabled + return true + } + // Can only be disabled if there's no media attachments yet. + return attachmentCount > 0 + } +} + +extension SendMediaNavigationController: PhotoCaptureViewControllerDataSource { + + var numberOfMediaItems: Int { + attachmentCount + } + + func addMedia(attachment: SignalAttachment) { + guard let sendMediaNavDelegate = self.sendMediaNavDelegate else { return } + let cameraCaptureAttachment = CameraCaptureAttachment(signalAttachment: attachment, canSave: sendMediaNavDelegate.sendMediaNavCanSaveAttachments) + attachmentDraftCollection.append(.camera(attachment: cameraCaptureAttachment)) } } extension SendMediaNavigationController: ImagePickerGridControllerDelegate { - func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController) { + func imagePickerDidRequestSendMedia(_ imagePicker: ImagePickerGridController) { + if let navigationController = presentedViewController as? OWSNavigationController, + navigationController.viewControllers.contains(imagePicker) { + dismiss(animated: true) { + self.showApprovalAfterProcessingAnyMediaLibrarySelections() + } + return + } showApprovalAfterProcessingAnyMediaLibrarySelections() } func imagePickerDidCancel(_ imagePicker: ImagePickerGridController) { + if let navigationController = presentedViewController as? OWSNavigationController, + navigationController.viewControllers.contains(imagePicker) { + dismiss(animated: true) + return + } + let dontAbandonText = NSLocalizedString("SEND_MEDIA_RETURN_TO_MEDIA_LIBRARY", comment: "alert action when the user decides not to cancel the media flow after all.") didRequestExit(dontAbandonText: dontAbandonText) } @@ -582,16 +399,9 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate { backgroundBlock: backgroundBlock) } - func imagePicker(_ imagePicker: ImagePickerGridController, isAssetSelected asset: PHAsset) -> Bool { - return attachmentDraftCollection.hasPickerAttachment(forAsset: asset) - } - func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPromise: Promise) { guard let sendMediaNavDelegate = sendMediaNavDelegate else { return } - - guard !attachmentDraftCollection.hasPickerAttachment(forAsset: asset) else { - return - } + guard !attachmentDraftCollection.hasPickerAttachment(forAsset: asset) else { return } let attachmentApprovalItemPromise = attachmentPromise.map { attachment in AttachmentApprovalItem(attachment: attachment, @@ -600,8 +410,6 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate { let libraryMedia = MediaLibraryAttachment(asset: asset, attachmentApprovalItemPromise: attachmentApprovalItemPromise) attachmentDraftCollection.append(.picker(attachment: libraryMedia)) - - updateViewState(topViewController: imagePicker, animated: false) } func imagePicker(_ imagePicker: ImagePickerGridController, didDeselectAsset asset: PHAsset) { @@ -609,12 +417,6 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate { return } attachmentDraftCollection.remove(.picker(attachment: draft)) - - updateViewState(topViewController: imagePicker, animated: false) - } - - func imagePickerCanSelectMoreItems(_ imagePicker: ImagePickerGridController) -> Bool { - return attachmentCount < SignalAttachment.maxAttachmentsAllowed } func imagePickerDidTryToSelectTooMany(_ imagePicker: ImagePickerGridController) { @@ -622,10 +424,21 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate { } } +extension SendMediaNavigationController: ImagePickerGridControllerDataSource { + + func imagePicker(_ imagePicker: ImagePickerGridController, isAssetSelected asset: PHAsset) -> Bool { + return attachmentDraftCollection.hasPickerAttachment(forAsset: asset) + } + + func imagePickerCanSelectMoreItems(_ imagePicker: ImagePickerGridController) -> Bool { + return attachmentCount < SignalAttachment.maxAttachmentsAllowed + } + +} + extension SendMediaNavigationController: AttachmentApprovalViewControllerDelegate { func attachmentApprovalDidAppear(_ attachmentApproval: AttachmentApprovalViewController) { - updateViewState(topViewController: attachmentApproval, animated: true) } func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageBody newMessageBody: MessageBody?) { @@ -653,8 +466,9 @@ extension SendMediaNavigationController: AttachmentApprovalViewControllerDelegat // Current design dicates we'll go "back" to the single thing before us. owsAssertDebug(viewControllers.count == 2) - // regardless of which VC we're going "back" to, we're in "batch" mode at this point. - isInBatchSelectMode = true + if let cameraViewController = viewControllers.first as? PhotoCaptureViewController { + cameraViewController.switchToBatchMode() + } popViewController(animated: true) } @@ -779,7 +593,7 @@ private struct CameraCaptureAttachment: Hashable, Equatable { hasher.combine(signalAttachment) } - static func ==(lhs: CameraCaptureAttachment, rhs: CameraCaptureAttachment) -> Bool { + static func == (lhs: CameraCaptureAttachment, rhs: CameraCaptureAttachment) -> Bool { return lhs.signalAttachment == rhs.signalAttachment } } @@ -792,102 +606,7 @@ private struct MediaLibraryAttachment: Hashable, Equatable { hasher.combine(asset) } - static func ==(lhs: MediaLibraryAttachment, rhs: MediaLibraryAttachment) -> Bool { + static func == (lhs: MediaLibraryAttachment, rhs: MediaLibraryAttachment) -> Bool { return lhs.asset == rhs.asset } } - -extension SendMediaNavigationController: DoneButtonDelegate { - var doneButtonCount: Int { - return attachmentCount - } - - fileprivate func doneButtonWasTapped(_ doneButton: DoneButton) { - owsAssertDebug(attachmentDraftCollection.count > 0) - showApprovalAfterProcessingAnyMediaLibrarySelections() - } -} - -private protocol DoneButtonDelegate: AnyObject { - func doneButtonWasTapped(_ doneButton: DoneButton) - var doneButtonCount: Int { get } -} - -private class DoneButton: UIView { - weak var delegate: DoneButtonDelegate? - - init() { - super.init(frame: .zero) - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap(tapGesture:))) - addGestureRecognizer(tapGesture) - - let container = PillView() - container.backgroundColor = .ows_white - container.layoutMargins = UIEdgeInsets(top: 7, leading: 8, bottom: 7, trailing: 8) - - addSubview(container) - container.autoPinEdgesToSuperviewMargins() - - let stackView = UIStackView(arrangedSubviews: [badge, chevron]) - stackView.axis = .horizontal - stackView.alignment = .center - stackView.spacing = 9 - - container.addSubview(stackView) - stackView.autoPinEdgesToSuperviewMargins() - } - - let numberFormatter: NumberFormatter = NumberFormatter() - - func updateCount() { - guard let delegate = delegate else { - return - } - - badgeLabel.text = numberFormatter.string(for: delegate.doneButtonCount) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Subviews - - private lazy var badge: UIView = { - let badge = PillView() - badge.layoutMargins = UIEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4) - badge.backgroundColor = .ows_accentBlue - badge.addSubview(badgeLabel) - badgeLabel.autoPinEdgesToSuperviewMargins() - - return badge - }() - - private lazy var badgeLabel: UILabel = { - let label = UILabel() - label.textColor = .ows_white - label.font = UIFont.ows_dynamicTypeSubheadline.ows_monospaced - label.textAlignment = .center - return label - }() - - private lazy var chevron: UIView = { - let image: UIImage - if CurrentAppContext().isRTL { - image = #imageLiteral(resourceName: "small_chevron_left") - } else { - image = #imageLiteral(resourceName: "small_chevron_right") - } - let chevron = UIImageView(image: image.withRenderingMode(.alwaysTemplate)) - chevron.contentMode = .scaleAspectFit - chevron.tintColor = .ows_gray60 - chevron.autoSetDimensions(to: CGSize(width: 10, height: 18)) - - return chevron - }() - - @objc - func didTap(tapGesture: UITapGestureRecognizer) { - delegate?.doneButtonWasTapped(self) - } -} diff --git a/Signal/src/views/PhotoGridViewCell.swift b/Signal/src/views/PhotoGridViewCell.swift index d7cb107a5d..2d1a55c272 100644 --- a/Signal/src/views/PhotoGridViewCell.swift +++ b/Signal/src/views/PhotoGridViewCell.swift @@ -1,7 +1,10 @@ // -// Copyright (c) 2020 Open Whisper Systems. All rights reserved. +// Copyright (c) 2022 Open Whisper Systems. All rights reserved. // +import SignalUI +import UIKit + public enum PhotoGridItemType { case photo, animated, video } @@ -18,8 +21,8 @@ public class PhotoGridViewCell: UICollectionViewCell { public let imageView: UIImageView private let contentTypeBadgeView: UIImageView - private let unselectedBadgeView: UIView - private let selectedBadgeView: UIImageView + private let outlineBadgeView: UIView + private let selectedBadgeView: UIView private let highlightedMaskView: UIView private let selectedMaskView: UIView @@ -28,7 +31,7 @@ public class PhotoGridViewCell: UICollectionViewCell { private static let videoBadgeImage = #imageLiteral(resourceName: "ic_gallery_badge_video") private static let animatedBadgeImage = #imageLiteral(resourceName: "ic_gallery_badge_gif") - private static let selectedBadgeImage = #imageLiteral(resourceName: "image_editor_checkmark_full").withRenderingMode(.alwaysTemplate) + private static let selectedBadgeImage = UIImage(named: "media-composer-checkmark") public var loadingColor = Theme.washColor override public var isSelected: Bool { @@ -45,15 +48,15 @@ public class PhotoGridViewCell: UICollectionViewCell { func updateSelectionState() { if isSelected { - unselectedBadgeView.isHidden = true + outlineBadgeView.isHidden = false selectedBadgeView.isHidden = false selectedMaskView.isHidden = false } else if allowsMultipleSelection { - unselectedBadgeView.isHidden = false + outlineBadgeView.isHidden = false selectedBadgeView.isHidden = true selectedMaskView.isHidden = true } else { - unselectedBadgeView.isHidden = true + outlineBadgeView.isHidden = true selectedBadgeView.isHidden = true selectedMaskView.isHidden = true } @@ -66,43 +69,49 @@ public class PhotoGridViewCell: UICollectionViewCell { } override init(frame: CGRect) { - self.imageView = UIImageView() + let selectionBadgeSize: CGFloat = 22 + let contentTypeBadgeSize: CGFloat = 12 + + imageView = UIImageView() imageView.contentMode = .scaleAspectFill - self.contentTypeBadgeView = UIImageView() + contentTypeBadgeView = UIImageView() contentTypeBadgeView.isHidden = true - self.selectedBadgeView = UIImageView() - selectedBadgeView.image = PhotoGridViewCell.selectedBadgeImage + selectedBadgeView = CircleView(diameter: selectionBadgeSize) + selectedBadgeView.backgroundColor = .ows_accentBlue selectedBadgeView.isHidden = true - selectedBadgeView.tintColor = .white + let checkmarkImageView = UIImageView(image: PhotoGridViewCell.selectedBadgeImage) + checkmarkImageView.tintColor = .white + selectedBadgeView.addSubview(checkmarkImageView) + checkmarkImageView.autoCenterInSuperview() - self.unselectedBadgeView = CircleView() - unselectedBadgeView.backgroundColor = .clear - unselectedBadgeView.layer.borderWidth = 0.5 - unselectedBadgeView.layer.borderColor = UIColor.white.cgColor + outlineBadgeView = CircleView() + outlineBadgeView.backgroundColor = .clear + outlineBadgeView.layer.borderWidth = 1.5 + outlineBadgeView.layer.borderColor = UIColor.ows_white.cgColor selectedBadgeView.isHidden = true - self.highlightedMaskView = UIView() + highlightedMaskView = UIView() highlightedMaskView.alpha = 0.2 highlightedMaskView.backgroundColor = Theme.darkThemePrimaryColor highlightedMaskView.isHidden = true - self.selectedMaskView = UIView() + selectedMaskView = UIView() selectedMaskView.alpha = 0.3 selectedMaskView.backgroundColor = Theme.darkThemeBackgroundColor selectedMaskView.isHidden = true super.init(frame: frame) - self.clipsToBounds = true + clipsToBounds = true - self.contentView.addSubview(imageView) - self.contentView.addSubview(contentTypeBadgeView) - self.contentView.addSubview(highlightedMaskView) - self.contentView.addSubview(selectedMaskView) - self.contentView.addSubview(unselectedBadgeView) - self.contentView.addSubview(selectedBadgeView) + contentView.addSubview(imageView) + contentView.addSubview(contentTypeBadgeView) + contentView.addSubview(highlightedMaskView) + contentView.addSubview(selectedMaskView) + contentView.addSubview(selectedBadgeView) + contentView.addSubview(outlineBadgeView) imageView.autoPinEdgesToSuperviewEdges() highlightedMaskView.autoPinEdgesToSuperviewEdges() @@ -110,20 +119,17 @@ public class PhotoGridViewCell: UICollectionViewCell { // Note assets were rendered to match exactly. We don't want to re-size with // content mode lest they become less legible. - let kContentTypeBadgeSize = CGSize(square: 12) + contentTypeBadgeView.autoSetDimensions(to: CGSize(square: contentTypeBadgeSize)) contentTypeBadgeView.autoPinEdge(toSuperviewEdge: .leading, withInset: 3) contentTypeBadgeView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 3) - contentTypeBadgeView.autoSetDimensions(to: kContentTypeBadgeSize) - let kUnselectedBadgeSize = CGSize(square: 22) - unselectedBadgeView.autoPinEdge(toSuperviewEdge: .trailing, withInset: 4) - unselectedBadgeView.autoPinEdge(toSuperviewEdge: .top, withInset: 4) - unselectedBadgeView.autoSetDimensions(to: kUnselectedBadgeSize) + outlineBadgeView.autoSetDimensions(to: CGSize(square: selectionBadgeSize)) + outlineBadgeView.autoPinEdge(toSuperviewEdge: .trailing, withInset: 6) + outlineBadgeView.autoPinEdge(toSuperviewEdge: .top, withInset: 6) - let kSelectedBadgeSize = CGSize(square: 22) - selectedBadgeView.autoSetDimensions(to: kSelectedBadgeSize) - selectedBadgeView.autoAlignAxis(.vertical, toSameAxisOf: unselectedBadgeView) - selectedBadgeView.autoAlignAxis(.horizontal, toSameAxisOf: unselectedBadgeView) + selectedBadgeView.autoSetDimensions(to: CGSize(square: selectionBadgeSize)) + selectedBadgeView.autoAlignAxis(.vertical, toSameAxisOf: outlineBadgeView) + selectedBadgeView.autoAlignAxis(.horizontal, toSameAxisOf: outlineBadgeView) } @available(*, unavailable, message: "Unimplemented") @@ -191,6 +197,6 @@ public class PhotoGridViewCell: UICollectionViewCell { highlightedMaskView.isHidden = true selectedMaskView.isHidden = true selectedBadgeView.isHidden = true - unselectedBadgeView.isHidden = true + outlineBadgeView.isHidden = true } } diff --git a/SignalUI/ViewControllers/AttachmentApproval/AttachmentApprovalViewController.swift b/SignalUI/ViewControllers/AttachmentApproval/AttachmentApprovalViewController.swift index 8dfe3dbd58..90cd5dc9df 100644 --- a/SignalUI/ViewControllers/AttachmentApproval/AttachmentApprovalViewController.swift +++ b/SignalUI/ViewControllers/AttachmentApproval/AttachmentApprovalViewController.swift @@ -237,6 +237,13 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapTouchInterceptorView(gesture:))) touchInterceptorView.addGestureRecognizer(tapGesture) + let bottomToolViewWidth = view.bounds.width + let bottomToolViewHeight = bottomToolView.systemLayoutSizeFitting(view.bounds.size, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel).height + bottomToolView.frame = CGRect(x: 0, y: view.bounds.maxY - bottomToolViewHeight, width: bottomToolViewWidth, height: bottomToolViewHeight) + UIView.performWithoutAnimation { + bottomToolView.setNeedsLayout() + bottomToolView.layoutIfNeeded() + } view.addSubview(bottomToolView) bottomToolView.autoPinWidthToSuperview() bottomToolViewBottomConstraint = bottomToolView.autoPinEdge(toSuperviewEdge: .bottom) diff --git a/SignalUI/ViewControllers/AttachmentApproval/AttachmentPrepViewController.swift b/SignalUI/ViewControllers/AttachmentApproval/AttachmentPrepViewController.swift index 979769a14f..f272052dfd 100644 --- a/SignalUI/ViewControllers/AttachmentApproval/AttachmentPrepViewController.swift +++ b/SignalUI/ViewControllers/AttachmentApproval/AttachmentPrepViewController.swift @@ -37,7 +37,13 @@ public class AttachmentPrepViewController: OWSViewController { private(set) var contentContainer: UIView! private var imageEditorView: ImageEditorView? + private var imageEditorViewConstraintsPortrait: [NSLayoutConstraint]? + private var imageEditorViewConstraintsLandscape: [NSLayoutConstraint]? + private var videoEditorView: VideoEditorView? + private var videoEditorViewConstraintsPortrait: [NSLayoutConstraint]? + private var videoEditorViewConstraintsLandscape: [NSLayoutConstraint]? + private var mediaMessageView: MediaMessageView? public var shouldHideControls: Bool { @@ -61,20 +67,20 @@ public class AttachmentPrepViewController: OWSViewController { // MARK: - View Lifecycle override public func loadView() { - self.view = UIView() + view = UIView(frame: UIScreen.main.bounds) + view.backgroundColor = .ows_black // Anything that should be shrunk when user pops keyboard lives in the contentContainer. - let contentContainer = UIView() - self.contentContainer = contentContainer + contentContainer = UIView(frame: view.bounds) view.addSubview(contentContainer) contentContainer.autoPinEdgesToSuperviewEdges() // Scroll View - used to zoom/pan on images and video - scrollView = UIScrollView() - contentContainer.addSubview(scrollView) + scrollView = UIScrollView(frame: contentContainer.bounds) scrollView.delegate = self scrollView.showsHorizontalScrollIndicator = false scrollView.showsVerticalScrollIndicator = false + contentContainer.addSubview(scrollView) // Panning should stop pretty soon after the user stops scrolling scrollView.decelerationRate = UIScrollView.DecelerationRate.fast @@ -85,26 +91,38 @@ public class AttachmentPrepViewController: OWSViewController { scrollView.autoPinEdgesToSuperviewEdges() - let backgroundColor = UIColor.black - self.view.backgroundColor = backgroundColor - // Create full screen container view so the scrollView // can compute an appropriate content size in which to center // our media view. let containerView = UIView.container() + containerView.frame = view.bounds scrollView.addSubview(containerView) containerView.autoPinEdgesToSuperviewEdges() - containerView.autoMatch(.height, to: .height, of: self.view) - containerView.autoMatch(.width, to: .width, of: self.view) + containerView.autoMatch(.height, to: .height, of: view) + containerView.autoMatch(.width, to: .width, of: view) + + let contentMarginTop = UIDevice.current.hasIPhoneXNotch ? CurrentAppContext().statusBarHeight : 0 if let imageEditorModel = attachmentApprovalItem.imageEditorModel { let imageEditorView = ImageEditorView(model: imageEditorModel, delegate: self) + imageEditorView.frame = view.bounds imageEditorView.configureSubviews() - self.imageEditorView = imageEditorView - + imageEditorView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(imageEditorView) - imageEditorView.autoPinEdgesToSuperviewEdges() + + imageEditorViewConstraintsPortrait = [ + imageEditorView.heightAnchor.constraint(equalTo: imageEditorView.widthAnchor, multiplier: 16/9), + imageEditorView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + imageEditorView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + imageEditorView.topAnchor.constraint(equalTo: view.topAnchor, constant: contentMarginTop) ] + imageEditorViewConstraintsLandscape = [ + imageEditorView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + imageEditorView.topAnchor.constraint(equalTo: view.topAnchor), + imageEditorView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + imageEditorView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ] + + self.imageEditorView = imageEditorView imageEditorUpdateNavigationBar() } else if let videoEditorModel = attachmentApprovalItem.videoEditorModel { @@ -112,11 +130,23 @@ public class AttachmentPrepViewController: OWSViewController { let videoEditorView = VideoEditorView(model: videoEditorModel, attachmentApprovalItem: attachmentApprovalItem, delegate: self) + videoEditorView.frame = view.bounds videoEditorView.configureSubviews() - self.videoEditorView = videoEditorView - + videoEditorView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(videoEditorView) - videoEditorView.autoPinEdgesToSuperviewEdges() + + videoEditorViewConstraintsPortrait = [ + videoEditorView.heightAnchor.constraint(equalTo: videoEditorView.widthAnchor, multiplier: 16/9), + videoEditorView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + videoEditorView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + videoEditorView.topAnchor.constraint(equalTo: view.topAnchor, constant: contentMarginTop) ] + videoEditorViewConstraintsLandscape = [ + videoEditorView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + videoEditorView.topAnchor.constraint(equalTo: view.topAnchor), + videoEditorView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + videoEditorView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ] + + self.videoEditorView = videoEditorView videoEditorUpdateNavigationBar() } else { @@ -127,6 +157,12 @@ public class AttachmentPrepViewController: OWSViewController { } } + public override func viewDidLoad() { + super.viewDidLoad() + + updateLayoutConstraints() + } + override public func viewWillAppear(_ animated: Bool) { Logger.debug("") @@ -159,6 +195,14 @@ public class AttachmentPrepViewController: OWSViewController { positionBlurTooltip() } + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if traitCollection.horizontalSizeClass != previousTraitCollection?.horizontalSizeClass { + updateLayoutConstraints() + } + } + // MARK: - Navigation Bar public func navigationBarItems() -> [UIView] { @@ -236,6 +280,37 @@ public class AttachmentPrepViewController: OWSViewController { } } + private func updateLayoutConstraints() { + let isPortraitLayout = traitCollection.horizontalSizeClass == .compact + + var constraintsToRemove: [NSLayoutConstraint]? + var constraintsToAdd: [NSLayoutConstraint]? + + if imageEditorView != nil { + if isPortraitLayout { + constraintsToRemove = imageEditorViewConstraintsLandscape + constraintsToAdd = imageEditorViewConstraintsPortrait + } else { + constraintsToRemove = imageEditorViewConstraintsPortrait + constraintsToAdd = imageEditorViewConstraintsLandscape + } + } else if videoEditorView != nil { + if isPortraitLayout { + constraintsToRemove = videoEditorViewConstraintsLandscape + constraintsToAdd = videoEditorViewConstraintsPortrait + } else { + constraintsToRemove = videoEditorViewConstraintsPortrait + constraintsToAdd = videoEditorViewConstraintsLandscape + } + } + if let constraintsToRemove = constraintsToRemove { + view.removeConstraints(constraintsToRemove) + } + if let constraintsToAdd = constraintsToAdd { + view.addConstraints(constraintsToAdd) + } + } + // MARK: - Tooltip private var shouldShowBlurTooltip: Bool { diff --git a/SignalUI/ViewControllers/OWSNavigationController.h b/SignalUI/ViewControllers/OWSNavigationController.h index de586f38c1..6ae3c43aaf 100644 --- a/SignalUI/ViewControllers/OWSNavigationController.h +++ b/SignalUI/ViewControllers/OWSNavigationController.h @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// Copyright (c) 2022 Open Whisper Systems. All rights reserved. // #import diff --git a/SignalUI/ViewControllers/OWSNavigationController.m b/SignalUI/ViewControllers/OWSNavigationController.m index 11a6c5f8dc..92260054ff 100644 --- a/SignalUI/ViewControllers/OWSNavigationController.m +++ b/SignalUI/ViewControllers/OWSNavigationController.m @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// Copyright (c) 2022 Open Whisper Systems. All rights reserved. // #import "OWSNavigationController.h" diff --git a/SignalUI/Views/CircleView.swift b/SignalUI/Views/CircleView.swift index 91f837d722..0fc874e036 100644 --- a/SignalUI/Views/CircleView.swift +++ b/SignalUI/Views/CircleView.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// Copyright (c) 2022 Open Whisper Systems. All rights reserved. // import UIKit @@ -43,12 +43,56 @@ public class CircleView: UIView { } } +@objc (OWSCircleBlurView) +public class CircleBlurView: UIVisualEffectView { + + @available(*, unavailable, message: "use other constructor instead.") + required public init?(coder aDecoder: NSCoder) { + notImplemented() + } + + @objc + public override init(effect: UIVisualEffect?) { + super.init(effect: effect) + frame = .zero + } + + @objc + public required init(effect: UIVisualEffect, diameter: CGFloat) { + super.init(effect: effect) + frame = .zero + + autoSetDimensions(to: CGSize(square: diameter)) + } + + @objc + override public var frame: CGRect { + didSet { + updateRadius() + } + } + + @objc + override public var bounds: CGRect { + didSet { + updateRadius() + } + } + + private func updateRadius() { + layer.cornerRadius = bounds.size.height / 2 + clipsToBounds = true + } +} + @objc (OWSPillView) -public class PillView: UIView { +open class PillView: UIView { public override init(frame: CGRect) { super.init(frame: frame) + layer.masksToBounds = true + // Constrain to be a pill that is at least a circle, and maybe wider. autoPin(toAspectRatio: 1.0, relation: .greaterThanOrEqual) @@ -59,7 +103,7 @@ public class PillView: UIView { } } - required init?(coder aDecoder: NSCoder) { + required public init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") }