From 3a102092eee7049423b532e216608103b0c1c28d Mon Sep 17 00:00:00 2001 From: Almaz Ibragimov Date: Mon, 29 Jan 2024 21:04:37 +0300 Subject: [PATCH] Bottom sheet improvements (#112) --- .xcode-version | 2 +- Nivelir.xcodeproj/project.pbxproj | 6 +++ .../ScreenShowActionSheetAction.swift | 2 +- Sources/Addons/BottomSheet/BottomSheet.swift | 5 ++ .../BottomSheet/BottomSheetController.swift | 9 +++- .../BottomSheetRubberBandEffect.swift | 23 +++++++++ .../BottomSheetDismissingInteraction.swift | 14 ++++-- .../BottomSheetPresentedInteraction.swift | 22 +++++--- .../BottomSheetPresentingInteraction.swift | 8 ++- .../BottomSheetPresentationController.swift | 10 ++-- Sources/Addons/Safari/Safari.swift | 2 +- .../Addons/Sharing/ScreenShareAction.swift | 2 +- .../DictionaryComponentDecoder.swift | 4 ++ .../DictionaryDecoderTesting.swift | 50 ++++++++++++++++--- .../DictionaryDecoderTests.swift | 19 ++++++- .../URLQueryDecoderTests.swift | 2 +- 16 files changed, 150 insertions(+), 30 deletions(-) create mode 100644 Sources/Addons/BottomSheet/BottomSheetRubberBandEffect.swift diff --git a/.xcode-version b/.xcode-version index c519231b..dafb659a 100644 --- a/.xcode-version +++ b/.xcode-version @@ -1 +1 @@ -14.3 \ No newline at end of file +15.2 diff --git a/Nivelir.xcodeproj/project.pbxproj b/Nivelir.xcodeproj/project.pbxproj index 603318ae..da51357c 100755 --- a/Nivelir.xcodeproj/project.pbxproj +++ b/Nivelir.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 8E41C80628AD38B400E3E1B2 /* Documentation.docc in Sources */ = {isa = PBXBuildFile; fileRef = 8E41C80528AD38B400E3E1B2 /* Documentation.docc */; }; 8E41C80728AD38B400E3E1B2 /* Documentation.docc in Sources */ = {isa = PBXBuildFile; fileRef = 8E41C80528AD38B400E3E1B2 /* Documentation.docc */; }; + C002FF202B67FE0900D07C8B /* BottomSheetRubberBandEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = C002FF1F2B67FE0900D07C8B /* BottomSheetRubberBandEffect.swift */; }; + C002FF212B67FE0900D07C8B /* BottomSheetRubberBandEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = C002FF1F2B67FE0900D07C8B /* BottomSheetRubberBandEffect.swift */; }; C01294B62859B2C80039D06B /* SharingActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C01294B52859B2C80039D06B /* SharingActivity.swift */; }; C01294B82859B2DA0039D06B /* SharingCustomActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C01294B72859B2DA0039D06B /* SharingCustomActivity.swift */; }; C01294BA2859B2EC0039D06B /* SharingCustomItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C01294B92859B2EC0039D06B /* SharingCustomItem.swift */; }; @@ -675,6 +677,7 @@ /* Begin PBXFileReference section */ 8E41C80528AD38B400E3E1B2 /* Documentation.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Documentation.docc; sourceTree = ""; }; C0007FFA23CBAAA5005F95E0 /* .swift-version */ = {isa = PBXFileReference; lastKnownFileType = text; path = ".swift-version"; sourceTree = ""; }; + C002FF1F2B67FE0900D07C8B /* BottomSheetRubberBandEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomSheetRubberBandEffect.swift; sourceTree = ""; }; C010082725EEA6B700A2FF1A /* Nivelir.podspec */ = {isa = PBXFileReference; lastKnownFileType = text; path = Nivelir.podspec; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; C01294B52859B2C80039D06B /* SharingActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharingActivity.swift; sourceTree = ""; }; C01294B72859B2DA0039D06B /* SharingCustomActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharingCustomActivity.swift; sourceTree = ""; }; @@ -1957,6 +1960,7 @@ C089D9102A0B1AC300B6400C /* BottomSheetCard.swift */, C089D90F2A0B1AC300B6400C /* BottomSheetController.swift */, C089D9092A0B1AC300B6400C /* BottomSheetGrabber.swift */, + C002FF1F2B67FE0900D07C8B /* BottomSheetRubberBandEffect.swift */, C089D9112A0B1AC300B6400C /* BottomSheetShadow.swift */, C089D9062A0B1AC300B6400C /* BottomSheetStackController.swift */, C089D90D2A0B1AC300B6400C /* BottomSheetTransitionView.swift */, @@ -2708,6 +2712,7 @@ C078FD5D2861A1DA0008FC6E /* ProgressFailureIndicator.swift in Sources */, C0959148285256CE00729E93 /* UNNotificationResponse+Extensions.swift in Sources */, C0A728EE279EF148000BFBF9 /* Character+Extensions.swift in Sources */, + C002FF202B67FE0900D07C8B /* BottomSheetRubberBandEffect.swift in Sources */, C089D9412A0B1AC300B6400C /* BottomSheetBorder.swift in Sources */, C088603726B6C60B00CA0A77 /* UnavailableMediaPickerSourceError.swift in Sources */, C078FD882861AAEC0008FC6E /* ScreenHideHUDAction.swift in Sources */, @@ -2917,6 +2922,7 @@ C0866B5B2853A59D00B85C54 /* ScreenGetAction.swift in Sources */, C05F488C26B68B7F0002DF52 /* AnyScreenActionBaseBox.swift in Sources */, C088602226B6C60B00CA0A77 /* InvalidMailParametersError.swift in Sources */, + C002FF212B67FE0900D07C8B /* BottomSheetRubberBandEffect.swift in Sources */, C0238F0527A7F77D00148E91 /* ShortcutDeeplinkUserInfoOptions.swift in Sources */, C0A728C1279AD889000BFBF9 /* DictionaryUnkeyedDecodingContainer.swift in Sources */, C052C7D12823A94500132875 /* ScreenObserverPredicate.swift in Sources */, diff --git a/Sources/Addons/ActionSheet/ScreenShowActionSheetAction.swift b/Sources/Addons/ActionSheet/ScreenShowActionSheetAction.swift index 5e22387a..c0fd2746 100644 --- a/Sources/Addons/ActionSheet/ScreenShowActionSheetAction.swift +++ b/Sources/Addons/ActionSheet/ScreenShowActionSheetAction.swift @@ -97,7 +97,7 @@ public struct ScreenShowActionSheetAction: ScreenAc let alertContainer = makeAlertContainer() switch UIDevice.current.userInterfaceIdiom { - case .pad, .mac: + case .pad, .mac, .vision: showAlertContainerUsingPopover( alertContainer, from: actionSheet.anchor, diff --git a/Sources/Addons/BottomSheet/BottomSheet.swift b/Sources/Addons/BottomSheet/BottomSheet.swift index 7fe0b4dd..29916244 100644 --- a/Sources/Addons/BottomSheet/BottomSheet.swift +++ b/Sources/Addons/BottomSheet/BottomSheet.swift @@ -21,6 +21,8 @@ public struct BottomSheet { public let presentAnimationOptions: BottomSheetAnimationOptions public let dismissAnimationOptions: BottomSheetAnimationOptions + public let rubberBandEffect: BottomSheetRubberBandEffect? + public let canEndEditing: (() -> Bool)? public let shouldDismiss: (() -> Bool)? @@ -44,6 +46,7 @@ public struct BottomSheet { changesAnimationOptions: BottomSheetAnimationOptions = .changes, presentAnimationOptions: BottomSheetAnimationOptions = .transition, dismissAnimationOptions: BottomSheetAnimationOptions = .transition, + rubberBandEffect: BottomSheetRubberBandEffect? = .default, canEndEditing: (() -> Bool)? = nil, shouldDismiss: (() -> Bool)? = nil, didAttemptToDismiss: (() -> Void)? = nil, @@ -67,6 +70,8 @@ public struct BottomSheet { self.presentAnimationOptions = presentAnimationOptions self.dismissAnimationOptions = dismissAnimationOptions + self.rubberBandEffect = rubberBandEffect + self.canEndEditing = canEndEditing self.shouldDismiss = shouldDismiss diff --git a/Sources/Addons/BottomSheet/BottomSheetController.swift b/Sources/Addons/BottomSheet/BottomSheetController.swift index 28a8f96f..5b97c293 100644 --- a/Sources/Addons/BottomSheet/BottomSheetController.swift +++ b/Sources/Addons/BottomSheet/BottomSheetController.swift @@ -88,6 +88,8 @@ public class BottomSheetController: NSObject { public var presentAnimationOptions: BottomSheetAnimationOptions public var dismissAnimationOptions: BottomSheetAnimationOptions + public var rubberBandEffect: BottomSheetRubberBandEffect? + public var canEndEditing: (() -> Bool)? public var shouldDismiss: (() -> Bool)? @@ -115,6 +117,8 @@ public class BottomSheetController: NSObject { self.presentAnimationOptions = bottomSheet.presentAnimationOptions self.dismissAnimationOptions = bottomSheet.dismissAnimationOptions + self.rubberBandEffect = bottomSheet.rubberBandEffect + self.canEndEditing = bottomSheet.canEndEditing self.shouldDismiss = bottomSheet.shouldDismiss @@ -192,8 +196,6 @@ extension BottomSheetController: UIViewControllerTransitioningDelegate { presentation.delegate = self presentation.detention.delegate = self - presentation.changesAnimationOptions = changesAnimationOptions - presentation.detents = detents presentation.selectedDetentKey = selectedDetentKey @@ -206,6 +208,9 @@ extension BottomSheetController: UIViewControllerTransitioningDelegate { presentation.prefersWidthFollowsPreferredContentSize = prefersWidthFollowsPreferredContentSize presentation.prefersEdgeAttachedInCompactHeight = prefersEdgeAttachedInCompactHeight + presentation.changesAnimationOptions = changesAnimationOptions + presentation.rubberBandEffect = rubberBandEffect + self.presentation = presentation return presentation diff --git a/Sources/Addons/BottomSheet/BottomSheetRubberBandEffect.swift b/Sources/Addons/BottomSheet/BottomSheetRubberBandEffect.swift new file mode 100644 index 00000000..1b27aba4 --- /dev/null +++ b/Sources/Addons/BottomSheet/BottomSheetRubberBandEffect.swift @@ -0,0 +1,23 @@ +#if canImport(UIKit) +import UIKit + +public struct BottomSheetRubberBandEffect { + + public let handler: (_ delta: CGFloat) -> CGFloat + + public init(handler: @escaping (_ delta: CGFloat) -> CGFloat) { + self.handler = handler + } + + public func callAsFunction(value: CGFloat, limit: CGFloat) -> CGFloat { + handler(abs(limit - value)) + } +} + +extension BottomSheetRubberBandEffect { + + public static let `default` = Self { delta in + 2.0 * delta.squareRoot() + } +} +#endif diff --git a/Sources/Addons/BottomSheet/Interaction/Interactions/BottomSheetDismissingInteraction.swift b/Sources/Addons/BottomSheet/Interaction/Interactions/BottomSheetDismissingInteraction.swift index acea8133..2bf52201 100644 --- a/Sources/Addons/BottomSheet/Interaction/Interactions/BottomSheetDismissingInteraction.swift +++ b/Sources/Addons/BottomSheet/Interaction/Interactions/BottomSheetDismissingInteraction.swift @@ -51,11 +51,17 @@ internal final class BottomSheetDismissingInteraction: BottomSheetInteraction { if gestureValue > largestDetentValue + .leastNonzeroMagnitude { let largestDetentDelta = largestDetentValue - currentDetentValue - let largestDetentExcess = gestureValue - largestDetentValue - return simultaneousScrollView?.canScrollVertically ?? false - ? largestDetentDelta - : largestDetentDelta + 2.0 * largestDetentExcess.squareRoot() + if simultaneousScrollView?.canScrollVertically ?? false { + return largestDetentDelta + } + + let rubberBandEffect = presentationController.rubberBandEffect?( + value: gestureValue, + limit: largestDetentValue + ) ?? .zero + + return largestDetentDelta + rubberBandEffect } return gestureValue - currentDetentValue diff --git a/Sources/Addons/BottomSheet/Interaction/Interactions/BottomSheetPresentedInteraction.swift b/Sources/Addons/BottomSheet/Interaction/Interactions/BottomSheetPresentedInteraction.swift index 265a7d16..426c5bd6 100644 --- a/Sources/Addons/BottomSheet/Interaction/Interactions/BottomSheetPresentedInteraction.swift +++ b/Sources/Addons/BottomSheet/Interaction/Interactions/BottomSheetPresentedInteraction.swift @@ -53,11 +53,17 @@ internal final class BottomSheetPresentedInteraction: BottomSheetInteraction { if gestureValue > largestDetentValue + .leastNonzeroMagnitude { let largestDetentDelta = largestDetentValue - currentDetentValue - let largestDetentExcess = gestureValue - largestDetentValue - return simultaneousScrollView?.canScrollVertically ?? false - ? largestDetentDelta - : largestDetentDelta + 2.0 * largestDetentExcess.squareRoot() + if simultaneousScrollView?.canScrollVertically ?? false { + return largestDetentDelta + } + + let rubberBandEffect = presentationController.rubberBandEffect?( + value: gestureValue, + limit: largestDetentValue + ) ?? .zero + + return largestDetentDelta + rubberBandEffect } let smallestDetentValue = presentationController @@ -66,9 +72,13 @@ internal final class BottomSheetPresentedInteraction: BottomSheetInteraction { if gestureValue < smallestDetentValue - .leastNonzeroMagnitude { let smallestDetentDelta = smallestDetentValue - currentDetentValue - let smallestDetentExcess = smallestDetentValue - gestureValue - return smallestDetentDelta - 2.0 * smallestDetentExcess.squareRoot() + let rubberBandEffect = presentationController.rubberBandEffect?( + value: gestureValue, + limit: smallestDetentValue + ) ?? .zero + + return smallestDetentDelta - rubberBandEffect } return gestureValue - currentDetentValue diff --git a/Sources/Addons/BottomSheet/Interaction/Interactions/BottomSheetPresentingInteraction.swift b/Sources/Addons/BottomSheet/Interaction/Interactions/BottomSheetPresentingInteraction.swift index 363ec820..7318a63a 100644 --- a/Sources/Addons/BottomSheet/Interaction/Interactions/BottomSheetPresentingInteraction.swift +++ b/Sources/Addons/BottomSheet/Interaction/Interactions/BottomSheetPresentingInteraction.swift @@ -43,9 +43,13 @@ internal final class BottomSheetPresentingInteraction: BottomSheetInteraction { if gestureValue > largestDetentValue + .leastNonzeroMagnitude { let largestDetentDelta = largestDetentValue - currentDetentValue - let largestDetentExcess = gestureValue - largestDetentValue - return largestDetentDelta + 2.0 * largestDetentExcess.squareRoot() + let rubberBandEffect = presentationController.rubberBandEffect?( + value: gestureValue, + limit: largestDetentValue + ) ?? .zero + + return largestDetentDelta + rubberBandEffect } return gestureValue - currentDetentValue diff --git a/Sources/Addons/BottomSheet/Presentation/BottomSheetPresentationController.swift b/Sources/Addons/BottomSheet/Presentation/BottomSheetPresentationController.swift index ddf53194..72228f5e 100644 --- a/Sources/Addons/BottomSheet/Presentation/BottomSheetPresentationController.swift +++ b/Sources/Addons/BottomSheet/Presentation/BottomSheetPresentationController.swift @@ -14,10 +14,6 @@ internal final class BottomSheetPresentationController: UIPresentationController didSet { interaction.handlePresentationState(state) } } - internal var changesAnimationOptions: BottomSheetAnimationOptions = .changes { - didSet { transition.completionCurve = changesAnimationOptions.curve } - } - internal var detents: [BottomSheetDetent] { get { detention.detents } @@ -97,6 +93,12 @@ internal final class BottomSheetPresentationController: UIPresentationController } } + internal var changesAnimationOptions: BottomSheetAnimationOptions = .changes { + didSet { transition.completionCurve = changesAnimationOptions.curve } + } + + internal var rubberBandEffect: BottomSheetRubberBandEffect? = .default + internal var isEdgeAttached: Bool { prefersEdgeAttachedInCompactHeight || (traitCollection.verticalSizeClass != .compact) } diff --git a/Sources/Addons/Safari/Safari.swift b/Sources/Addons/Safari/Safari.swift index 9db3ce7b..d38b5eb2 100644 --- a/Sources/Addons/Safari/Safari.swift +++ b/Sources/Addons/Safari/Safari.swift @@ -11,7 +11,7 @@ public struct Safari: CustomStringConvertible { /// The URL to navigate to. The URL must use the http or https scheme. public let url: URL - /// The color to tint the background of the navigation bar and the toolbar. + /// The color to tint the background of the navigation bar and th toolbar. public let preferredBarTintColor: UIColor? /// The color to tint the control buttons on the navigation bar and the toolbar. diff --git a/Sources/Addons/Sharing/ScreenShareAction.swift b/Sources/Addons/Sharing/ScreenShareAction.swift index a843270e..1a94cf04 100644 --- a/Sources/Addons/Sharing/ScreenShareAction.swift +++ b/Sources/Addons/Sharing/ScreenShareAction.swift @@ -109,7 +109,7 @@ public struct ScreenShareAction: ScreenAction { let activitiesContainer = makeActivitiesContainer(navigator: navigator) switch UIDevice.current.userInterfaceIdiom { - case .pad, .mac: + case .pad, .mac, .vision: showActivitiesContainerInPopover( activitiesContainer, on: container, diff --git a/Sources/Tools/DictionaryDecoder/DictionaryComponentDecoder.swift b/Sources/Tools/DictionaryDecoder/DictionaryComponentDecoder.swift index 17d924d3..cfe6406b 100644 --- a/Sources/Tools/DictionaryDecoder/DictionaryComponentDecoder.swift +++ b/Sources/Tools/DictionaryDecoder/DictionaryComponentDecoder.swift @@ -162,6 +162,10 @@ extension DictionaryComponentDecoder { } private func decodeURL(from component: Any?, at codingPath: [CodingKey]) throws -> URL { + if let url = component as? URL { + return url + } + guard let url = URL(string: try decodePrimitiveValue(from: component, at: codingPath)) else { let errorContext = DecodingError.Context( codingPath: codingPath, diff --git a/Tests/Tools/DictionaryDecoder/DictionaryDecoderTesting.swift b/Tests/Tools/DictionaryDecoder/DictionaryDecoderTesting.swift index 6b9be99c..6feae332 100644 --- a/Tests/Tools/DictionaryDecoder/DictionaryDecoderTesting.swift +++ b/Tests/Tools/DictionaryDecoder/DictionaryDecoderTesting.swift @@ -25,15 +25,14 @@ extension DictionaryDecoderTesting { return try jsonDecoder.decode(T.self, from: data) } - func assertDecoderSucceeds( - decoding valueType: [Key: Value].Type, + func assertDecoderSucceeds( + decoding expectedValue: [Key: Value], from dictionary: [String: Any], file: StaticString = #file, line: UInt = #line ) { do { - let expectedValue = try makeExpectedValue(valueType, from: dictionary) - let value = try decoder.decode(valueType, from: dictionary) + let value = try decoder.decode([Key: Value].self, from: dictionary) XCTAssertEqual( NSDictionary(dictionary: value), @@ -46,6 +45,41 @@ extension DictionaryDecoderTesting { } } + func assertDecoderSucceeds( + decoding valueType: [Key: Value].Type, + from dictionary: [String: Any], + file: StaticString = #file, + line: UInt = #line + ) { + do { + let expectedValue = try makeExpectedValue(valueType, from: dictionary) + + assertDecoderSucceeds( + decoding: expectedValue, + from: dictionary, + file: file, + line: line + ) + } catch { + XCTFail("Test encountered unexpected error: \(error)", file: file, line: line) + } + } + + func assertDecoderSucceeds( + decoding expectedValue: T, + from dictionary: [String: Any], + file: StaticString = #file, + line: UInt = #line + ) { + do { + let value = try decoder.decode(T.self, from: dictionary) + + XCTAssertEqual(value, expectedValue, file: file, line: line) + } catch { + XCTFail("Test encountered unexpected error: \(error)", file: file, line: line) + } + } + func assertDecoderSucceeds( decoding valueType: T.Type, from dictionary: [String: Any], @@ -54,9 +88,13 @@ extension DictionaryDecoderTesting { ) { do { let expectedValue = try makeExpectedValue(valueType, from: dictionary) - let value = try decoder.decode(valueType, from: dictionary) - XCTAssertEqual(value, expectedValue, file: file, line: line) + assertDecoderSucceeds( + decoding: expectedValue, + from: dictionary, + file: file, + line: line + ) } catch { XCTFail("Test encountered unexpected error: \(error)", file: file, line: line) } diff --git a/Tests/Tools/DictionaryDecoder/DictionaryDecoderTests.swift b/Tests/Tools/DictionaryDecoder/DictionaryDecoderTests.swift index 858717f2..de89f0fb 100644 --- a/Tests/Tools/DictionaryDecoder/DictionaryDecoderTests.swift +++ b/Tests/Tools/DictionaryDecoder/DictionaryDecoderTests.swift @@ -202,6 +202,23 @@ final class DictionaryDecoderTests: XCTestCase, DictionaryDecoderTesting { assertDecoderSucceeds(decoding: DecodableStruct.self, from: dictionary) } + func testThatDecoderSucceedsWhenDecodingStructWithURL() { + struct DecodableStruct: Decodable, Equatable { + let foobar: URL? + } + + let url = URL(string: "https://apple.com")! + + let dictionary = [ + "foobar": "https://apple.com" + ] + + assertDecoderSucceeds( + decoding: DecodableStruct(foobar: url), + from: dictionary + ) + } + func testThatDecoderSucceedsWhenDecodingStructWithMultipleProperties() { struct DecodableStruct: Decodable, Equatable { let foo: Bool @@ -562,7 +579,7 @@ final class DictionaryDecoderTests: XCTestCase, DictionaryDecoderTesting { } func testThatDecoderFailsWhenDecodingInvalidURL() { - let dictionary = ["foobar": "invalid url"] + let dictionary = ["foobar": "//invalid url"] assertDecoderFails(decoding: [String: URL].self, from: dictionary) { error in switch error { diff --git a/Tests/Tools/URLQueryDecoder/URLQueryDecoderTests.swift b/Tests/Tools/URLQueryDecoder/URLQueryDecoderTests.swift index 7c615697..4cf30b67 100644 --- a/Tests/Tools/URLQueryDecoder/URLQueryDecoderTests.swift +++ b/Tests/Tools/URLQueryDecoder/URLQueryDecoderTests.swift @@ -859,7 +859,7 @@ final class URLQueryDecoderTests: XCTestCase, URLQueryDecoderTesting { } func testThatDecoderFailsWhenDecodingInvalidURL() { - let url = "invalid url" + let url = "//invalid url" let query = "foobar=\(url.urlQueryEncoded!)" assertDecoderFails(decoding: [String: URL].self, from: query) { error in