diff --git a/.jazzy.yaml b/.jazzy.yaml index c89f7d1..f7f62c3 100644 --- a/.jazzy.yaml +++ b/.jazzy.yaml @@ -1,5 +1,5 @@ module: MaterialMotion -module_version: 1.0.0 +module_version: 1.1.0 sdk: iphonesimulator xcodebuild_arguments: - -workspace @@ -7,4 +7,4 @@ xcodebuild_arguments: - -scheme - MaterialMotion github_url: https://github.com/material-motion/material-motion-swift -github_file_prefix: https://github.com/material-motion/material-motion-swift/tree/v1.0.0 +github_file_prefix: https://github.com/material-motion/material-motion-swift/tree/v1.1.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6daf7ef..9923f79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,143 @@ +# 1.1.0 + +This is our first minor release. It includes two new interactions and improvements to APIs for the common cases. + + + +## Behavioral changes + +- TransitionSpring's configuration now defaults to Core Animation's default values. If you prefer to continue using the original Spring defaults you can use the following snippet: + +```swift +spring.tension.value = defaultSpringTension +spring.friction.value = defaultSpringFriction +spring.mass.value = defaultSpringMass +spring.suggestedDuration.value = 0 +``` + +## New deprecations + +- Tossable's `init(system:draggable:)` is deprecated in favor of `init(spring:draggable:)`. + +## New features + +New interactions: `ChangeDirection` and `SlopRegion`. + +Gesturable interactions can now be initialized with a sequence of gesture recognizers. This makes it easier to create gesturable interactions in transitions where the gesture recognizers are provided as a set. + +Spring's system now defaults to Core Animation. + +There is a new API for getting a gesture recognizer delegate that's able to coordinate a "drag to dismiss" transition with a vertical scroll view. + +```swift +let pan = UIPanGestureRecognizer() +pan.delegate = transitionController.dismisser.topEdgeDismisserDelegate(for: scrollView) +``` + +## Source changes + +* [Store interactions before adding them so that order is maintained when interactions add other interactions.](https://github.com/material-motion/material-motion-swift/commit/5d0af5fa77b913c0706837d1f0bbf3e14ce9f1ad) (Jeff Verkoeyen) +* [Avoid over-completion of chained property animations.](https://github.com/material-motion/material-motion-swift/commit/51ee058ddc3a15164f4a3c22c522cf3e6fd280c9) (Jeff Verkoeyen) +* [Fix bug causing properties-chained-to-properties to not animate correctly.](https://github.com/material-motion/material-motion-swift/commit/9e810bbf0ddce9764c76ea44dc223ccee4fca0b7) (Jeff Verkoeyen) +* [Add SlopRegion interaction.](https://github.com/material-motion/material-motion-swift/commit/2655adc561181c0679f52df2ca23646abf7876cb) (Jeff Verkoeyen) +* [Add topEdgeDismisserDelegate API to ViewControllerDismisser.](https://github.com/material-motion/material-motion-swift/commit/ad32ba9e7200b59e799d57c0c964b19be55089c3) (Jeff Verkoeyen) +* [Add Gesturable convenience initializer for extracting the first gesture recognizer from a sequence of gesture recognizers.](https://github.com/material-motion/material-motion-swift/commit/c4e0b3d0bf5c2b53e0046c08b63f0592b99b4b46) (Jeff Verkoeyen) +* [Rename ChangeDirectionOnRelease(of:) to ChangeDirection(withVelocityOf:)](https://github.com/material-motion/material-motion-swift/commit/c999a3a5134331e79f3cce74d2be222780155054) (Jeff Verkoeyen) +* [Fix crashing bug when connecting properties to one another.](https://github.com/material-motion/material-motion-swift/commit/840d97cd68321dd241e157ed9527cdc83ed73489) (Jeff Verkoeyen) +* [Add ChangeDirectionOnRelease interaction.](https://github.com/material-motion/material-motion-swift/commit/bdee45533c68f2c98e7b543acf9e3589a783eb46) (Jeff Verkoeyen) +* [Make TransitionSpring and Spring T type conform to Subtractable so that coreAnimation can be set as the default system.](https://github.com/material-motion/material-motion-swift/commit/419327972893b92a4317e903ad933550d096a76f) (Jeff Verkoeyen) +* [TransitionSpring configuration now defaults to Core Animation configuration defaults.](https://github.com/material-motion/material-motion-swift/commit/295d64556048429a5ec6d7859a0ce8922b84a4fd) (Jeff Verkoeyen) + +## API changes + +Auto-generated by running: + + apidiff origin/stable release-candidate swift MaterialMotion.xcworkspace MaterialMotion + +### New global constants + +*new* global var: `defaultTransitionSpringFriction` + +*new* global var: `defaultTransitionSpringSuggestedDuration` + +*new* global var: `defaultTransitionSpringTension` + +*new* global var: `defaultTransitionSpringMass` + +### New interactions + +*new* class: `ChangeDirection` + +*new* class: `SlopRegion` + +### Modified interactions + +#### Gesturable + +Affects `Draggable`, `Rotatable`, and `Scalable`. + +*new* method: `init(withFirstGestureIn:)` in `Gesturable` + +#### Spring + +| Type of change: | Declaration | +|---|---| +| From: | `public class Spring : Interaction, Togglable, Stateful where T : Zeroable` | +| To: | `public class Spring : Interaction, Togglable, Stateful where T : Subtractable, T : Zeroable` | + +*modified* method: `init(threshold:system:)` in `Spring` + +| Type of change: | Declaration | +|---|---| +| From: | `public init(threshold: CGFloat, system: @escaping SpringToStream)` | +| To: | `public init(threshold: CGFloat = 1, system: @escaping SpringToStream = coreAnimation)` | + +#### Tossable + +*modified* method: `init(spring:draggable:)` in `Tossable` + +| Type of change: | Declaration | +|---|---| +| From: | `public init(spring: Spring, draggable: Draggable = Draggable())` | +| To: | `public init(spring: Spring = Spring(), draggable: Draggable = Draggable())` | + +*deprecated* method: `init(system:draggable:)` in `Tossable`. Use `init(spring:draggable:)` instead. + +#### TransitionSpring + +*modified* class: `TransitionSpring` + +| Type of change: | Declaration | +|---|---| +| From: | `public final class TransitionSpring : Spring where T : Zeroable` | +| To: | `public final class TransitionSpring : Spring where T : Subtractable, T : Zeroable` | + +*modified* method: `init(back:fore:direction:threshold:system:)` in `TransitionSpring` + +| Type of change: | Declaration | +|---|---| +| From: | `public init(back backwardDestination: T, fore forwardDestination: T, direction: ReactiveProperty, threshold: CGFloat, system: @escaping SpringToStream)` | +| To: | `public init(back backwardDestination: T, fore forwardDestination: T, direction: ReactiveProperty, threshold: CGFloat = default, system: @escaping SpringToStream = default)` | + +### Transitions + +#### ViewControllerDismisser + +*new* method: `topEdgeDismisserDelegate(for:)` in `ViewControllerDismisser` + +### Stream changes + +*new* var: `onCompletion` in `CoreAnimationChannelAdd` + +*removed* var: `onCompletion` in `CoreAnimationChannelAdd` + +## Non-source changes + +* [Modify the PushBackTransition example to use connected properties instead of multiple springs.](https://github.com/material-motion/material-motion-swift/commit/71f06c23ec9ef8f460ad48de6f6af540f1eec4c9) (Jeff Verkoeyen) +* [Simplify the interactive push back transition example.](https://github.com/material-motion/material-motion-swift/commit/b8620649aae2eadf93ec66c82b164759a067931d) (Jeff Verkoeyen) +* [Add syntax highlighting languages to the README.](https://github.com/material-motion/material-motion-swift/commit/486cff7dc497a66c5479f8117a599cf778fa575a) (Jeff Verkoeyen) +* [Add example Podfile to the README.](https://github.com/material-motion/material-motion-swift/commit/687e6cc01dc82bd114a5f4f913cee54dc5071806) (Jeff Verkoeyen) + # 1.0.0 Initial stable release of Material Motion. Includes: diff --git a/MaterialMotion.podspec b/MaterialMotion.podspec index 972a60b..d4c168b 100644 --- a/MaterialMotion.podspec +++ b/MaterialMotion.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "MaterialMotion" s.summary = "Reactive motion driven by Core Animation." - s.version = "1.0.0" + s.version = "1.1.0" s.authors = "The Material Motion Authors" s.license = "Apache 2.0" s.homepage = "https://github.com/material-motion/material-motion-swift" diff --git a/Podfile.lock b/Podfile.lock index 3652598..9b6bb85 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -3,7 +3,7 @@ PODS: - IndefiniteObservable (3.1.0): - IndefiniteObservable/lib (= 3.1.0) - IndefiniteObservable/lib (3.1.0) - - MaterialMotion (1.0.0): + - MaterialMotion (1.1.0): - IndefiniteObservable (~> 3.0) DEPENDENCIES: @@ -17,7 +17,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: CatalogByConvention: be55c2263132e4f9f59299ac8a528ee8715b3275 IndefiniteObservable: 2789d61f487d8d37fa2b9c3153cc44d4447ff744 - MaterialMotion: 967eed88a2f4add305ea8768bbc49283283c1e83 + MaterialMotion: 6ee4d44d39b074686d603c26c20a5816afdb50cd PODFILE CHECKSUM: f503265a0d60526a0d28c96dd4bdcfb40fb562fc diff --git a/README.md b/README.md index 2987105..2856328 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,12 @@ arcMove.from.value = <#from#> arcMove.to.value = <#to#> runtime.add(arcMove, to: <#view#>) + + +
ChangeDirection
+
runtime.add(ChangeDirection(withVelocityOf: gesture),
+            to: <#view#>)
+
DirectlyManipulable
@@ -91,17 +97,38 @@ runtime.add(tossable, to: <#view#>) Add `MaterialMotion` to your `Podfile`: - pod 'MaterialMotion' +```ruby +pod 'MaterialMotion' +``` + +You will need to add `use_frameworks!` to your Podfile in order use Material Motion in your swift +app. + +A simple Podfile might look like so: + +```ruby +project 'MyApp/MyApp.xcodeproj' + +use_frameworks! + +target 'MyApp' do + pod 'MaterialMotion' +end +``` Then run the following command: - pod install +```bash +pod install +``` ### Usage Import the framework: - import MaterialMotion +```swift +import MaterialMotion +``` You will now have access to all of the APIs. @@ -110,10 +137,12 @@ You will now have access to all of the APIs. Check out a local copy of the repo to access the Catalog application by running the following commands: - git clone https://github.com/material-motion/material-motion-swift.git - cd material-motion-swift - pod install - open MaterialMotion.xcworkspace +```bash +git clone https://github.com/material-motion/material-motion-swift.git +cd material-motion-swift +pod install +open MaterialMotion.xcworkspace +``` ## Case studies @@ -168,6 +197,16 @@ Makes use of: `Tossable` and `TransitionSpring`. [View the source](examples/ModalDialogExample.swift). +### Pull down to dismiss + + + +A modal scroll view controller that can be dismissed with a drag gesture. + +Makes use of: `Tossable` and `TransitionSpring`. + +[View the source](examples/InteractivePushBackTransitionExample.swift). + ### Sticker picker diff --git a/assets/changedirection.gif b/assets/changedirection.gif new file mode 100644 index 0000000..0970b85 Binary files /dev/null and b/assets/changedirection.gif differ diff --git a/assets/pulldowntodismiss.gif b/assets/pulldowntodismiss.gif new file mode 100644 index 0000000..7711bd1 Binary files /dev/null and b/assets/pulldowntodismiss.gif differ diff --git a/examples/ChangeDirectionOnReleaseExample.swift b/examples/ChangeDirectionOnReleaseExample.swift new file mode 100644 index 0000000..259d092 --- /dev/null +++ b/examples/ChangeDirectionOnReleaseExample.swift @@ -0,0 +1,56 @@ +/* + Copyright 2016-present The Material Motion Authors. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import UIKit +import MaterialMotion + +class ChangeDirectionOnReleaseExampleViewController: ExampleViewController { + + var runtime: MotionRuntime! + + override func viewDidLoad() { + super.viewDidLoad() + + let targetView = center(createExampleSquareView(), within: view) + targetView.layer.borderColor = targetView.backgroundColor?.cgColor + targetView.layer.borderWidth = 1 + targetView.backgroundColor = nil + view.addSubview(targetView) + + let exampleView = center(createExampleView(), within: view) + view.addSubview(exampleView) + + runtime = MotionRuntime(containerView: view) + + let direction = createProperty(withInitialValue: TransitionDirection.backward) + + let positionSpring = TransitionSpring(back: CGPoint(x: view.bounds.midX, y: view.bounds.height * 4 / 10), + fore: CGPoint(x: view.bounds.midX, y: view.bounds.height * 6 / 10), + direction: direction) + let tossable = Tossable(spring: positionSpring) + runtime.add(ChangeDirection(withVelocityOf: tossable.draggable.nextGestureRecognizer, + whenNegative: .backward, + whenPositive: .forward), + to: direction) + runtime.add(tossable, to: exampleView) + runtime.add(positionSpring, to: runtime.get(targetView.layer).position) + } + + override func exampleInformation() -> ExampleInfo { + return .init(title: type(of: self).catalogBreadcrumbs().last!, + instructions: "Toss the view to change its position.") + } +} diff --git a/examples/ContextualTransitionExample.swift b/examples/ContextualTransitionExample.swift index 2cc9f81..0767da9 100644 --- a/examples/ContextualTransitionExample.swift +++ b/examples/ContextualTransitionExample.swift @@ -269,62 +269,27 @@ private class PushBackTransition: Transition { foreImageView.bounds.height / imageSize.height) let fitSize = CGSize(width: fitScale * imageSize.width, height: fitScale * imageSize.height) - let firstPan = ctx.gestureRecognizers.first { $0 is UIPanGestureRecognizer } - let draggable: Draggable - if let firstPan = firstPan as? UIPanGestureRecognizer { - draggable = Draggable(.withExistingRecognizer(firstPan)) - } else { - draggable = Draggable() - } + let draggable = Draggable(withFirstGestureIn: ctx.gestureRecognizers) + + runtime.add(SlopRegion(withTranslationOf: draggable.nextGestureRecognizer, size: 100), to: ctx.direction) + runtime.add(ChangeDirection(withVelocityOf: draggable.nextGestureRecognizer), to: ctx.direction) - let gesture = runtime.get(draggable.nextGestureRecognizer) - runtime.connect(gesture - .translation(in: runtime.containerView) - .y() - .slop(size: 100) - .rewrite([.onExit: .backward, .onReturn: .forward]), - to: ctx.direction) - runtime.connect(gesture - .velocityOnReleaseStream() - .y() - .thresholdRange(min: -100, max: 100) - .rewrite([.below: .backward, .above: .backward]), - to: ctx.direction) - - let movement = spring(back: contextView, fore: foreImageView, ctx: ctx) + let backPosition = contextView.superview!.convert(contextView.layer.position, to: ctx.containerView()) + let forePosition = foreImageView.superview!.convert(foreImageView.layer.position, to: ctx.containerView()) + let movement = TransitionSpring(back: backPosition, fore: forePosition, direction: ctx.direction) let tossable = Tossable(spring: movement, draggable: draggable) runtime.add(tossable, to: replicaView) - let size = spring(back: contextView.bounds.size, fore: fitSize, threshold: 1, ctx: ctx) + let size = TransitionSpring(back: contextView.bounds.size, fore: fitSize, direction: ctx.direction) runtime.toggle(size, inReactionTo: draggable) runtime.add(size, to: runtime.get(replicaView).reactiveLayer.size) - let opacity = spring(back: CGFloat(0), fore: CGFloat(1), threshold: 0.01, ctx: ctx) + let opacity = TransitionSpring(back: 0, fore: 1, direction: ctx.direction) runtime.add(opacity, to: runtime.get(ctx.fore.view.layer).opacity) runtime.add(Hidden(), to: foreImageView) - return [tossable.spring, gesture, size, opacity] - } - - private func spring(back: T, fore: T, threshold: CGFloat, ctx: TransitionContext) -> TransitionSpring where T: Subtractable, T: Zeroable, T: Equatable { - let spring = TransitionSpring(back: back, fore: fore, direction: ctx.direction, threshold: threshold, system: coreAnimation) - spring.friction.value = 500 - spring.tension.value = 1000 - spring.mass.value = 3 - spring.suggestedDuration.value = 0.5 - return spring - } - - private func spring(back: UIView, fore: UIView, ctx: TransitionContext) -> TransitionSpring { - let backPosition = back.superview!.convert(back.layer.position, to: ctx.containerView()) - let forePosition = fore.superview!.convert(fore.layer.position, to: ctx.containerView()) - let spring = TransitionSpring(back: backPosition, fore: forePosition, direction: ctx.direction, threshold: 1, system: coreAnimation) - spring.friction.value = 500 - spring.tension.value = 1000 - spring.mass.value = 3 - spring.suggestedDuration.value = 0.5 - return spring + return [tossable, size, opacity] } } diff --git a/examples/InteractivePushBackTransitionExample.swift b/examples/InteractivePushBackTransitionExample.swift index a77b73c..4ffaa6b 100644 --- a/examples/InteractivePushBackTransitionExample.swift +++ b/examples/InteractivePushBackTransitionExample.swift @@ -36,7 +36,7 @@ class InteractivePushBackTransitionExampleViewController: ExampleViewController } } -private class ModalViewController: UIViewController { +private class ModalViewController: UIViewController, UIGestureRecognizerDelegate { override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) @@ -48,13 +48,20 @@ private class ModalViewController: UIViewController { fatalError("init(coder:) has not been implemented") } + var scrollView: UIScrollView! override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .primaryColor + scrollView = UIScrollView(frame: view.bounds) + scrollView.contentSize = .init(width: view.bounds.width, height: view.bounds.height * 10) + view.addSubview(scrollView) + let pan = UIPanGestureRecognizer() + pan.delegate = transitionController.dismisser.topEdgeDismisserDelegate(for: scrollView) transitionController.dismisser.dismissWhenGestureRecognizerBegins(pan) + scrollView.panGestureRecognizer.require(toFail: pan) view.addGestureRecognizer(pan) } } @@ -64,63 +71,31 @@ private class PushBackTransition: Transition { required init() {} func willBeginTransition(withContext ctx: TransitionContext, runtime: MotionRuntime) -> [Stateful] { - let firstPan = ctx.gestureRecognizers.first { $0 is UIPanGestureRecognizer } - let draggable: Draggable - if let firstPan = firstPan as? UIPanGestureRecognizer { - draggable = Draggable(.withExistingRecognizer(firstPan)) - } else { - draggable = Draggable() - } - - let gesture = runtime.get(draggable.nextGestureRecognizer) - runtime.connect(gesture - .velocityOnReleaseStream() - .y() - .thresholdRange(min: -100, max: 100) - .rewrite([.below: .forward, - .within: ctx.direction.value, - .above: .backward]), + let draggable = Draggable(withFirstGestureIn: ctx.gestureRecognizers) + + runtime.add(ChangeDirection(withVelocityOf: draggable.nextGestureRecognizer, whenNegative: .forward), to: ctx.direction) let bounds = ctx.containerView().bounds let backPosition = CGPoint(x: bounds.midX, y: bounds.maxY + ctx.fore.view.bounds.height / 2) let forePosition = CGPoint(x: bounds.midX, y: bounds.midY) - let movement = spring(back: backPosition, - fore: forePosition, - threshold: 1, - ctx: ctx) - let scaleSpring = spring(back: CGFloat(1), fore: CGFloat(0.95), threshold: 0.005, ctx: ctx) + let movement = TransitionSpring(back: backPosition, + fore: forePosition, + direction: ctx.direction) let scale = runtime.get(ctx.back.view.layer).scale - runtime.connect(runtime.get(ctx.fore.view.layer).position.y() - // The position's final value gets written to by Core Animation when the gesture ends and the - // movement spring engages. Because we're connecting position to the scale here, this would - // also cause scale to jump to its destination as well (without animating, unfortunately). - // To ensure that we don't receive this information, we valve the stream based on the gesture - // activity and ensure that we register this valve *before* committing Tossable to the - // runtime. - .valve(openWhenTrue: gesture.active()) - .rewriteRange(start: movement.backwardDestination.y, - end: movement.forwardDestination.y, - destinationStart: scaleSpring.backwardDestination, - destinationEnd: scaleSpring.forwardDestination), - to: scale) let tossable = Tossable(spring: movement, draggable: draggable) - runtime.add(tossable, to: ctx.fore.view) { $0.xLocked(to: ctx.fore.view.layer.position.x) } - runtime.toggle(scaleSpring, inReactionTo: draggable) - runtime.add(scaleSpring, to: scale) + runtime.connect(runtime.get(ctx.fore.view.layer).position.y() + .rewriteRange(start: movement.backwardDestination.y, + end: movement.forwardDestination.y, + destinationStart: 1, + destinationEnd: 0.95), + to: scale) - return [tossable.spring, scaleSpring, gesture] - } + runtime.add(tossable, to: ctx.fore.view) { $0.xLocked(to: bounds.midX) } - private func spring(back: T, fore: T, threshold: CGFloat, ctx: TransitionContext) -> TransitionSpring where T: Subtractable, T: Zeroable, T: Equatable { - let spring = TransitionSpring(back: back, fore: fore, direction: ctx.direction, threshold: threshold, system: coreAnimation) - spring.friction.value = 500 - spring.tension.value = 1000 - spring.mass.value = 3 - spring.suggestedDuration.value = 0.5 - return spring + return [tossable] } } diff --git a/examples/ModalDialogExample.swift b/examples/ModalDialogExample.swift index 6f972af..ca44606 100644 --- a/examples/ModalDialogExample.swift +++ b/examples/ModalDialogExample.swift @@ -84,36 +84,28 @@ class ModalDialogTransition: SelfDismissingTransition { let backPosition = CGPoint(x: bounds.midX, y: bounds.maxY + size.height * 3 / 4) let forePosition = CGPoint(x: bounds.midX, y: bounds.midY) - let firstPan = ctx.gestureRecognizers.first { $0 is UIPanGestureRecognizer } - let draggable: Draggable - if let firstPan = firstPan as? UIPanGestureRecognizer { - draggable = Draggable(.withExistingRecognizer(firstPan)) - } else { - draggable = Draggable() - } - let reactiveForeLayer = runtime.get(ctx.fore.view.layer) let position = reactiveForeLayer.position + let draggable = Draggable(withFirstGestureIn: ctx.gestureRecognizers) + let gesture = runtime.get(draggable.nextGestureRecognizer) let centerY = ctx.containerView().bounds.height / 2.0 + + runtime.add(ChangeDirection(withVelocityOf: draggable.nextGestureRecognizer, whenNegative: .forward), + to: ctx.direction) + runtime.connect(gesture .velocityOnReleaseStream() .y() .thresholdRange(min: -100, max: 100) - // If one of rewrite's target values is a stream, then all the target values must be - // streams. - .rewrite([.below: createProperty(withInitialValue: .forward).asStream(), - .within: position.y().threshold(centerY).rewrite([.below: .forward, - .above: .backward]), - .above: createProperty(withInitialValue: .backward).asStream()]), + .rewrite([.within: position.y().threshold(centerY).rewrite([.below: .forward, + .above: .backward])]), to: ctx.direction) let movement = TransitionSpring(back: backPosition, fore: forePosition, - direction: ctx.direction, - threshold: 1, - system: coreAnimation) + direction: ctx.direction) let tossable = Tossable(spring: movement, draggable: draggable) runtime.add(tossable, to: ctx.fore.view) diff --git a/examples/PushBackTransitionExample.swift b/examples/PushBackTransitionExample.swift index 6cbc6ea..27cae79 100644 --- a/examples/PushBackTransitionExample.swift +++ b/examples/PushBackTransitionExample.swift @@ -66,24 +66,22 @@ private class PushBackTransition: Transition { required init() {} func willBeginTransition(withContext ctx: TransitionContext, runtime: MotionRuntime) -> [Stateful] { - let position = spring(back: ctx.containerView().bounds.height + ctx.fore.view.layer.bounds.height / 2, - fore: ctx.containerView().bounds.midY, - threshold: 1, - ctx: ctx) - let scale = spring(back: 1, fore: 0.95, threshold: 0.005, ctx: ctx) + let bounds = ctx.containerView().bounds + let backPosition = bounds.maxY + ctx.fore.view.bounds.height / 2 + let forePosition = bounds.midY - runtime.add(position, to: runtime.get(ctx.fore.view.layer).positionY) - runtime.add(scale, to: runtime.get(ctx.back.view.layer).scale) + let movement = TransitionSpring(back: backPosition, fore: forePosition, direction: ctx.direction) - return [position, scale] - } + let yPosition = runtime.get(ctx.fore.view.layer).positionY + + runtime.connect(yPosition.rewriteRange(start: movement.backwardDestination, + end: movement.forwardDestination, + destinationStart: CGFloat(1), + destinationEnd: CGFloat(0.95)), + to: runtime.get(ctx.back.view.layer).scale) + + runtime.add(movement, to: yPosition) - private func spring(back: CGFloat, fore: CGFloat, threshold: CGFloat, ctx: TransitionContext) -> TransitionSpring { - let spring = TransitionSpring(back: back, fore: fore, direction: ctx.direction, threshold: threshold, system: coreAnimation) - spring.friction.value = 500 - spring.tension.value = 1000 - spring.mass.value = 3 - spring.suggestedDuration.value = 0.5 - return spring + return [movement] } } diff --git a/examples/StickerPickerExample.swift b/examples/StickerPickerExample.swift index c34334d..895f679 100644 --- a/examples/StickerPickerExample.swift +++ b/examples/StickerPickerExample.swift @@ -37,7 +37,7 @@ class StickerPickerExampleViewController: ExampleViewController, StickerListView view.addSubview(stickerView) let direction = createProperty(withInitialValue: TransitionDirection.forward) - let spring = TransitionSpring(back: CGFloat(1.5), fore: 1, direction: direction, threshold: 0.1, system: coreAnimation) + let spring = TransitionSpring(back: CGFloat(1.5), fore: 1, direction: direction) runtime.add(spring, to: runtime.get(stickerView.layer).scale) runtime.add(DirectlyManipulable(), to: stickerView) @@ -201,11 +201,7 @@ private class ModalTransition: Transition { ctx.fore.view.bounds = CGRect(origin: .zero, size: size) } - let spring = TransitionSpring(back: CGFloat(0), - fore: CGFloat(1), - direction: ctx.direction, - threshold: 0.01, - system: coreAnimation) + let spring = TransitionSpring(back: 0, fore: 1, direction: ctx.direction) runtime.add(spring, to: runtime.get(ctx.fore.view.layer).opacity) return [spring] diff --git a/examples/apps/Catalog/Catalog/TableOfContents.swift b/examples/apps/Catalog/Catalog/TableOfContents.swift index 32ff40c..517756b 100644 --- a/examples/apps/Catalog/Catalog/TableOfContents.swift +++ b/examples/apps/Catalog/Catalog/TableOfContents.swift @@ -62,6 +62,10 @@ extension ArcMoveExampleViewController { class func catalogBreadcrumbs() -> [String] { return ["Interactions", "Arc move"] } } +extension ChangeDirectionOnReleaseExampleViewController { + class func catalogBreadcrumbs() -> [String] { return ["Interactions", "Change direction on release"] } +} + extension DirectlyManipulableExampleViewController { class func catalogBreadcrumbs() -> [String] { return ["Interactions", "Directly manipulable"] } } diff --git a/examples/apps/Catalog/MaterialMotionCatalog.xcodeproj/project.pbxproj b/examples/apps/Catalog/MaterialMotionCatalog.xcodeproj/project.pbxproj index e06876b..60fe369 100644 --- a/examples/apps/Catalog/MaterialMotionCatalog.xcodeproj/project.pbxproj +++ b/examples/apps/Catalog/MaterialMotionCatalog.xcodeproj/project.pbxproj @@ -71,6 +71,7 @@ 66DDFD161E71F90F00AA46B7 /* HowToUseReactiveConstraintsExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66DDFD151E71F90F00AA46B7 /* HowToUseReactiveConstraintsExample.swift */; }; 66DDFD201E73281200AA46B7 /* HowToMakeACustomOperatorExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66DDFD1F1E73281200AA46B7 /* HowToMakeACustomOperatorExample.swift */; }; 66E6F2371E3FC791003158D3 /* PhotoAlbum.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 66E6F2361E3FC791003158D3 /* PhotoAlbum.xcassets */; }; + 66F09E511E88B7EF00FC8D1B /* ChangeDirectionOnReleaseExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66F09E501E88B7EF00FC8D1B /* ChangeDirectionOnReleaseExample.swift */; }; 66F2C3C91E83245800DD9728 /* invertedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66F2C3C81E83245800DD9728 /* invertedTests.swift */; }; D3F169473691C7D44E72DEED /* Pods_UnitTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D89BD1C45FD980DEF396AB66 /* Pods_UnitTests.framework */; }; /* End PBXBuildFile section */ @@ -157,6 +158,7 @@ 66DDFD151E71F90F00AA46B7 /* HowToUseReactiveConstraintsExample.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = HowToUseReactiveConstraintsExample.swift; path = ../../HowToUseReactiveConstraintsExample.swift; sourceTree = ""; }; 66DDFD1F1E73281200AA46B7 /* HowToMakeACustomOperatorExample.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = HowToMakeACustomOperatorExample.swift; path = ../../HowToMakeACustomOperatorExample.swift; sourceTree = ""; }; 66E6F2361E3FC791003158D3 /* PhotoAlbum.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = PhotoAlbum.xcassets; sourceTree = SOURCE_ROOT; }; + 66F09E501E88B7EF00FC8D1B /* ChangeDirectionOnReleaseExample.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ChangeDirectionOnReleaseExample.swift; path = ../../ChangeDirectionOnReleaseExample.swift; sourceTree = ""; }; 66F2C3C81E83245800DD9728 /* invertedTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = invertedTests.swift; sourceTree = ""; }; 773711C342E83B1FF1F9C8BB /* Pods-MaterialMotionCatalog.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MaterialMotionCatalog.debug.xcconfig"; path = "../../../Pods/Target Support Files/Pods-MaterialMotionCatalog/Pods-MaterialMotionCatalog.debug.xcconfig"; sourceTree = ""; }; 800852A3E58C3B077D36E8DD /* Pods-MaterialMotionCatalog.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MaterialMotionCatalog.release.xcconfig"; path = "../../../Pods/Target Support Files/Pods-MaterialMotionCatalog/Pods-MaterialMotionCatalog.release.xcconfig"; sourceTree = ""; }; @@ -224,6 +226,7 @@ 660DA31C1E7A07BC008F7401 /* SpringExample.swift */, 660DA3151E79F6A3008F7401 /* TossableExample.swift */, 660DA3201E7A106D008F7401 /* TweenExample.swift */, + 66F09E501E88B7EF00FC8D1B /* ChangeDirectionOnReleaseExample.swift */, ); name = Interactions; sourceTree = ""; @@ -618,6 +621,7 @@ files = ( 667E945B1E71EF0E005CAC78 /* StickerPickerExample.swift in Sources */, 667E94551E71EF0E005CAC78 /* DirectlyManipulableExample.swift in Sources */, + 66F09E511E88B7EF00FC8D1B /* ChangeDirectionOnReleaseExample.swift in Sources */, 666FAA841D384A6B000363DA /* AppDelegate.swift in Sources */, 660DA3171E79F6A3008F7401 /* TossableExample.swift in Sources */, 660DA30F1E79EA62008F7401 /* RotatableExample.swift in Sources */, diff --git a/examples/apps/Catalog/ReactivePlayground.playground/Pages/How to combine interactions.xcplaygroundpage/Contents.swift b/examples/apps/Catalog/ReactivePlayground.playground/Pages/How to combine interactions.xcplaygroundpage/Contents.swift index 8428eaa..0e37cac 100644 --- a/examples/apps/Catalog/ReactivePlayground.playground/Pages/How to combine interactions.xcplaygroundpage/Contents.swift +++ b/examples/apps/Catalog/ReactivePlayground.playground/Pages/How to combine interactions.xcplaygroundpage/Contents.swift @@ -14,7 +14,7 @@ let runtime = MotionRuntime(containerView: canvas) //: --- //: //: First we create the interactions we know we'll need: Spring and Draggable. -let spring = Spring(threshold: 1, system: coreAnimation) +let spring = Spring() let draggable = Draggable() //: Inspecting Draggable's documentation reveals that it will affect the view's layer position (Option-Click Draggable), so let's make sure we use the same property for our spring: diff --git a/examples/apps/Catalog/ReactivePlayground.playground/Pages/How to create new interactions.xcplaygroundpage/Contents.swift b/examples/apps/Catalog/ReactivePlayground.playground/Pages/How to create new interactions.xcplaygroundpage/Contents.swift index a96fda4..7e3fcb6 100644 --- a/examples/apps/Catalog/ReactivePlayground.playground/Pages/How to create new interactions.xcplaygroundpage/Contents.swift +++ b/examples/apps/Catalog/ReactivePlayground.playground/Pages/How to create new interactions.xcplaygroundpage/Contents.swift @@ -22,7 +22,7 @@ final class MyTossable: Interaction { let draggable: Draggable let spring: Spring - init(spring: Spring, draggable: Draggable = Draggable()) { + init(spring: Spring = Spring(), draggable: Draggable = Draggable()) { self.spring = spring self.draggable = draggable } @@ -46,7 +46,7 @@ final class MyTossable: Interaction { //: Using our new interaction is a matter of instantiating it and associating it with a view: -let tossable = MyTossable(spring: .init(threshold: 1, system: coreAnimation)) +let tossable = MyTossable() runtime.add(tossable, to: view) runtime.add(SetPositionOnTap(), to: tossable.spring.destination) diff --git a/examples/apps/Catalog/ReactivePlayground.playground/Pages/How to use springs.xcplaygroundpage/Contents.swift b/examples/apps/Catalog/ReactivePlayground.playground/Pages/How to use springs.xcplaygroundpage/Contents.swift index ef218ae..c6306cc 100644 --- a/examples/apps/Catalog/ReactivePlayground.playground/Pages/How to use springs.xcplaygroundpage/Contents.swift +++ b/examples/apps/Catalog/ReactivePlayground.playground/Pages/How to use springs.xcplaygroundpage/Contents.swift @@ -21,7 +21,7 @@ let runtime = MotionRuntime(containerView: canvas) let position = runtime.get(view.layer).position //: Next we'll create our Spring interaction. We must specify the type of Spring we'd like to use because Spring is a [generic type](https://developer.apple.com/library/content/documentation/Swift/Conceptual/Swift_Programming_Language/Generics.html). In this case we want to animate a CGPoint, so we'll define that here: -let spring = Spring(threshold: 1, system: coreAnimation) +let spring = Spring() //: Starting the spring animation is now a simple matter of adding it to the property: runtime.add(spring, to: position) diff --git a/src/MotionRuntime.swift b/src/MotionRuntime.swift index ea37d77..782abc5 100644 --- a/src/MotionRuntime.swift +++ b/src/MotionRuntime.swift @@ -61,8 +61,8 @@ public final class MotionRuntime { runtime. */ public func add(_ interaction: I, to target: I.Target, constraints: I.Constraints? = nil) { - interaction.add(to: target, withRuntime: self, constraints: constraints) interactions.append(interaction) + interaction.add(to: target, withRuntime: self, constraints: constraints) } /** diff --git a/src/ReactiveProperty.swift b/src/ReactiveProperty.swift index 95163b8..214c0c6 100644 --- a/src/ReactiveProperty.swift +++ b/src/ReactiveProperty.swift @@ -113,8 +113,19 @@ public final class ReactiveProperty { func coreAnimation(_ event: CoreAnimationChannelEvent) { _coreAnimation?(event) + let transformedEvent: CoreAnimationChannelEvent + switch event { + case .add(var info): + // This is a hack-fix to ensure that animations don't over-complete they're connected to other + // properties. + // Related to https://github.com/material-motion/material-motion-swift/issues/65 + info.onCompletion = nil + transformedEvent = .add(info) + default: + transformedEvent = event + } for observer in observers { - observer.coreAnimation?(event) + observer.coreAnimation?(transformedEvent) } } diff --git a/src/interactions/ChangeDirection.swift b/src/interactions/ChangeDirection.swift new file mode 100644 index 0000000..0e1dfc9 --- /dev/null +++ b/src/interactions/ChangeDirection.swift @@ -0,0 +1,97 @@ +/* + Copyright 2017-present The Material Motion Authors. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import UIKit + +/** + Changes the direction of a transition when the provided pan gesture recognizer completes. + + Without any configuration, will set the direction to backward if the absolute velocity exceeds + minimumThreshold on the y axis. + + **Common configurations** + + *Modal dialogs and sheets*: cancel a dismissal when tossing up using `whenNegative: .forward`. + + **Constraints** + + Either the x or y axis can be selected. The default axis is y. + */ +public final class ChangeDirection: Interaction { + /** + The gesture recognizer that will be observed by this interaction. + */ + public let gesture: UIPanGestureRecognizer + + /** + The minimum absolute velocity required change the transition's direction. + + If this velocity is not met, the direction will not be changed. + */ + public let minimumVelocity: CGFloat + + /** + The transition direction to emit when the velocity is below -minimumVelocity. + */ + public let whenNegative: TransitionDirection + + /** + The transition direction to emit when the velocity is above minimumVelocity. + */ + public let whenPositive: TransitionDirection + + /** + - parameter minimumVelocity: The minimum absolute velocity required to change the transition's direction. + */ + public init(withVelocityOf gesture: UIPanGestureRecognizer, minimumVelocity: CGFloat = 100, whenNegative: TransitionDirection = .backward, whenPositive: TransitionDirection = .backward) { + self.gesture = gesture + self.minimumVelocity = minimumVelocity + self.whenNegative = whenNegative + self.whenPositive = whenPositive + } + + /** + The velocity axis to observe. + */ + public enum Axis { + /** + Observes the velocity's x axis. + */ + case x + + /** + Observes the velocity's y axis. + */ + case y + } + + public func add(to direction: ReactiveProperty, withRuntime runtime: MotionRuntime, constraints axis: Axis?) { + let axis = axis ?? .y + let chooseAxis: (MotionObservable) -> MotionObservable + switch axis { + case .x: + chooseAxis = { $0.x() } + case .y: + chooseAxis = { $0.y() } + } + runtime.connect(chooseAxis(runtime.get(gesture).velocityOnReleaseStream()) + .thresholdRange(min: -minimumVelocity, max: minimumVelocity) + .rewrite([.below: whenNegative, .above: whenPositive]), + to: direction) + + } +} diff --git a/src/interactions/SlopRegion.swift b/src/interactions/SlopRegion.swift new file mode 100644 index 0000000..01da615 --- /dev/null +++ b/src/interactions/SlopRegion.swift @@ -0,0 +1,73 @@ +/* + Copyright 2017-present The Material Motion Authors. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import UIKit + +/** + Changes the direction of a transition when the provided pan gesture recognizer moves out of or back + into a slop region. + + **Constraints** + + Either the x or y axis can be selected. The default axis is y. + */ +public final class SlopRegion: Interaction { + /** + The gesture recognizer that will be observed by this interaction. + */ + public let gesture: UIPanGestureRecognizer + + /** + The size of the slop region. + */ + public let size: CGFloat + + public init(withTranslationOf gesture: UIPanGestureRecognizer, size: CGFloat) { + self.gesture = gesture + self.size = size + } + + /** + The axis to observe. + */ + public enum Axis { + /** + Observes the x axis. + */ + case x + + /** + Observes the y axis. + */ + case y + } + + public func add(to direction: ReactiveProperty, withRuntime runtime: MotionRuntime, constraints axis: Axis?) { + let axis = axis ?? .y + let chooseAxis: (MotionObservable) -> MotionObservable + switch axis { + case .x: + chooseAxis = { $0.x() } + case .y: + chooseAxis = { $0.y() } + } + + runtime.connect(chooseAxis(runtime.get(gesture).translation(in: runtime.containerView)) + .slop(size: size).rewrite([.onExit: .backward, .onReturn: .forward]), + to: direction) + } +} diff --git a/src/interactions/Spring.swift b/src/interactions/Spring.swift index 325f608..92726ad 100644 --- a/src/interactions/Spring.swift +++ b/src/interactions/Spring.swift @@ -45,14 +45,14 @@ public let defaultSpringMass: CGFloat = 1 T-value constraints may be applied to this interaction. */ -public class Spring: Interaction, Togglable, Stateful { +public class Spring: Interaction, Togglable, Stateful where T: Zeroable, T: Subtractable { /** Creates a spring with a given threshold and system. - - parameter threshold: The threshold of movement defining the completion of the spring simulation. - - parameter system: Often coreAnimation. Can be another system if a system support library is available. + - parameter threshold: The threshold of movement defining the completion of the spring simulation. This parameter is not used by the Core Animation system and can be left as a default value. + - parameter system: The system that should be used to drive this spring. */ - public init(threshold: CGFloat, system: @escaping SpringToStream) { + public init(threshold: CGFloat = 1, system: @escaping SpringToStream = coreAnimation) { self.threshold = createProperty("Spring.threshold", withInitialValue: threshold) self.system = system } @@ -140,7 +140,7 @@ public class Spring: Interaction, Togglable, Stateful { private var activeSprings = Set>() } -public struct SpringShadow: Hashable { +public struct SpringShadow: Hashable where T: Zeroable, T: Subtractable { public let enabled: ReactiveProperty public let state = createProperty(withInitialValue: MotionState.atRest) public let initialValue: ReactiveProperty diff --git a/src/interactions/Tossable.swift b/src/interactions/Tossable.swift index 494966f..aa0d6f9 100644 --- a/src/interactions/Tossable.swift +++ b/src/interactions/Tossable.swift @@ -48,12 +48,7 @@ public final class Tossable: Interaction, Stateful { */ public let spring: Spring - public init(system: @escaping SpringToStream = coreAnimation, draggable: Draggable = Draggable()) { - self.spring = Spring(threshold: 1, system: system) - self.draggable = draggable - } - - public init(spring: Spring, draggable: Draggable = Draggable()) { + public init(spring: Spring = Spring(), draggable: Draggable = Draggable()) { self.spring = spring self.draggable = draggable } @@ -91,4 +86,9 @@ public final class Tossable: Interaction, Stateful { } let aggregateState = AggregateMotionState() + + @available(*, deprecated, message: "Use init(spring:draggable:) instead.") + public convenience init(system: @escaping SpringToStream, draggable: Draggable = Draggable()) { + self.init(spring: Spring(threshold: 1, system: system), draggable: draggable) + } } diff --git a/src/interactions/TransitionSpring.swift b/src/interactions/TransitionSpring.swift index 515a9ff..c661042 100644 --- a/src/interactions/TransitionSpring.swift +++ b/src/interactions/TransitionSpring.swift @@ -17,6 +17,26 @@ import Foundation import UIKit +/** + The default transition spring tension configuration. + */ +public let defaultTransitionSpringTension: CGFloat = 500 + +/** + The default transition spring friction configuration. + */ +public let defaultTransitionSpringFriction: CGFloat = 1000 + +/** + The default transition spring mass configuration. + */ +public let defaultTransitionSpringMass: CGFloat = 3 + +/** + The default transition spring suggested duration. + */ +public let defaultTransitionSpringSuggestedDuration: CGFloat = 0.5 + /** A transition spring pulls a value from one side of a transition to another. @@ -41,7 +61,7 @@ import UIKit T-value constraints may be applied to this interaction. */ -public final class TransitionSpring: Spring { +public final class TransitionSpring: Spring where T: Zeroable, T: Subtractable { /** The destination when the transition is moving backward. @@ -59,20 +79,26 @@ public final class TransitionSpring: Spring { - parameter back: The destination to which the spring will pull the view when transitioning backward. - parameter fore: The destination to which the spring will pull the view when transitioning forward. - parameter direction: The spring will change its destination in reaction to this property's changes. - - parameter threshold: The threshold of movement defining the completion of the spring simulation. - - parameter system: Often coreAnimation. Can be another system if a system support library is available. + - parameter threshold: The threshold of movement defining the completion of the spring simulation. This parameter is not used by the Core Animation system and can be left as a default value. + - parameter system: The system that should be used to drive this spring. */ public init(back backwardDestination: T, fore forwardDestination: T, direction: ReactiveProperty, - threshold: CGFloat, - system: @escaping SpringToStream) { + threshold: CGFloat = 1, + system: @escaping SpringToStream = coreAnimation) { self.backwardDestination = backwardDestination self.forwardDestination = forwardDestination self.initialValue = direction == .forward ? backwardDestination : forwardDestination self.toggledDestination = direction.rewrite([.backward: backwardDestination, .forward: forwardDestination]) super.init(threshold: threshold, system: system) + + // Apply Core Animation transition spring defaults. + friction.value = defaultTransitionSpringTension + tension.value = defaultTransitionSpringFriction + mass.value = defaultTransitionSpringMass + suggestedDuration.value = defaultTransitionSpringSuggestedDuration } public override func add(to property: ReactiveProperty, diff --git a/src/protocols/Gesturable.swift b/src/protocols/Gesturable.swift index 43038d1..94dc5b5 100644 --- a/src/protocols/Gesturable.swift +++ b/src/protocols/Gesturable.swift @@ -56,6 +56,25 @@ public class Gesturable { self.init(.registerNewRecognizerToTargetView) } + /** + Initializes the interaction with the first gesture recognizer that matches the interaction's T + type. + */ + public convenience init(withFirstGestureIn gestures: O) where O: Sequence, O.Iterator.Element == UIGestureRecognizer { + var first: T? = nil + for gesture in gestures { + if let gesture = gesture as? T { + first = gesture + break + } + } + if let first = first { + self.init(.withExistingRecognizer(first)) + } else { + self.init() + } + } + public init(_ config: GesturableConfiguration) { self.config = config diff --git a/src/protocols/Systems.swift b/src/protocols/Systems.swift index 58ebe8f..5e0de15 100644 --- a/src/protocols/Systems.swift +++ b/src/protocols/Systems.swift @@ -36,7 +36,13 @@ public typealias ScrollViewToStream = (UIScrollView) -> MotionObservable = (TweenShadow) -> MotionObservable +/** + Swift 3.1 does not allow generic typealiases to use protocol lists, so we define this composite + type instead. + */ +public protocol ZeroableAndSubtractable: Zeroable, Subtractable {} + /** A spring-to-stream function creates a MotionObservable from a Spring and initial value stream. */ -public typealias SpringToStream = (SpringShadow) -> MotionObservable +public typealias SpringToStream = (SpringShadow) -> MotionObservable diff --git a/src/reactivetypes/MotionObservable.swift b/src/reactivetypes/MotionObservable.swift index a91ec74..ee9a243 100644 --- a/src/reactivetypes/MotionObservable.swift +++ b/src/reactivetypes/MotionObservable.swift @@ -74,7 +74,7 @@ public struct CoreAnimationChannelAdd { /** A completion handler, fired once Core Animation reports that the animation has completed. */ - public let onCompletion: () -> Void + public var onCompletion: (() -> Void)? /** The initial velocity of the animation, if relevant. diff --git a/src/reactivetypes/ReactiveCALayer.swift b/src/reactivetypes/ReactiveCALayer.swift index fe91ef9..1b83d8b 100644 --- a/src/reactivetypes/ReactiveCALayer.swift +++ b/src/reactivetypes/ReactiveCALayer.swift @@ -207,14 +207,27 @@ public func createCoreAnimationProperty(_ name: String, initialValue: T, exte animation.keyPath = keyPath - if let makeAdditive = info.makeAdditive, let basicAnimation = animation as? CABasicAnimation { - basicAnimation.fromValue = makeAdditive(basicAnimation.fromValue!, basicAnimation.toValue!) - basicAnimation.toValue = makeAdditive(basicAnimation.toValue!, basicAnimation.toValue!) - basicAnimation.isAdditive = true - } else if let makeAdditive = info.makeAdditive, let keyframeAnimation = animation as? CAKeyframeAnimation { - let lastValue = keyframeAnimation.values!.last! - keyframeAnimation.values = keyframeAnimation.values!.map { makeAdditive($0, lastValue) } - keyframeAnimation.isAdditive = true + if let unsafeMakeAdditive = info.makeAdditive { + let makeAdditive: ((Any, Any) -> Any) = { from, to in + // When mapping properties to properties it's possible for the values to get implicitly + // wrapped in an NSNumber instance. This may cause the generic makeAdditive + // implementation to fail to cast to T, so we unbox the type here instead. + if let from = from as? NSNumber, let to = to as? NSNumber { + return from.doubleValue - to.doubleValue + } + return unsafeMakeAdditive(from, to) + } + + if let basicAnimation = animation as? CABasicAnimation { + basicAnimation.fromValue = makeAdditive(basicAnimation.fromValue!, basicAnimation.toValue!) + basicAnimation.toValue = makeAdditive(basicAnimation.toValue!, basicAnimation.toValue!) + basicAnimation.isAdditive = true + + } else if let keyframeAnimation = animation as? CAKeyframeAnimation { + let lastValue = keyframeAnimation.values!.last! + keyframeAnimation.values = keyframeAnimation.values!.map { makeAdditive($0, lastValue) } + keyframeAnimation.isAdditive = true + } } // Core Animation springs do not support multi-dimensional velocity, so we bear the burden @@ -243,7 +256,7 @@ public func createCoreAnimationProperty(_ name: String, initialValue: T, exte CATransaction.begin() CATransaction.setCompletionBlock(info.onCompletion) - layer.add(animation, forKey: info.key) + layer.add(animation, forKey: info.key + "." + keyPath) CATransaction.commit() case .remove(let key): @@ -256,7 +269,7 @@ public func createCoreAnimationProperty(_ name: String, initialValue: T, exte strongReactiveLayer.decomposedKeys.remove(key) } else { - layer.removeAnimation(forKey: key) + layer.removeAnimation(forKey: key + "." + keyPath) } } }) diff --git a/src/transitions/ViewControllerDismisser.swift b/src/transitions/ViewControllerDismisser.swift index 883fd4e..009782c 100644 --- a/src/transitions/ViewControllerDismisser.swift +++ b/src/transitions/ViewControllerDismisser.swift @@ -33,6 +33,24 @@ public final class ViewControllerDismisser: NSObject { soloGestureRecognizers.insert(gestureRecognizer) } + /** + Returns a gesture recognizer delegate that will allow the gesture recognizer to begin only if the + provided scroll view is scrolled to the top of its content. + + The returned delegate implements gestureRecognizerShouldBegin. + */ + public func topEdgeDismisserDelegate(for scrollView: UIScrollView) -> UIGestureRecognizerDelegate { + for delegate in scrollViewTopEdgeDismisserDelegates { + if delegate.scrollView == scrollView { + return delegate + } + } + let delegate = ScrollViewTopEdgeDismisserDelegate() + delegate.scrollView = scrollView + scrollViewTopEdgeDismisserDelegates.append(delegate) + return delegate + } + @objc func gestureRecognizerDidChange(_ gestureRecognizer: UIGestureRecognizer) { if gestureRecognizer.state == .began || gestureRecognizer.state == .recognized { delegate?.dismiss() @@ -42,6 +60,20 @@ public final class ViewControllerDismisser: NSObject { weak var delegate: ViewControllerDismisserDelegate? private(set) var gestureRecognizers = Set() fileprivate var soloGestureRecognizers = Set() + + private var scrollViewTopEdgeDismisserDelegates: [ScrollViewTopEdgeDismisserDelegate] = [] +} + +private final class ScrollViewTopEdgeDismisserDelegate: NSObject, UIGestureRecognizerDelegate { + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if let pan = gestureRecognizer as? UIPanGestureRecognizer, let scrollView = scrollView { + return pan.translation(in: pan.view).y > 0 + && scrollView.contentOffset.y <= -scrollView.contentInset.top + } + return false + } + + weak var scrollView: UIScrollView? } extension ViewControllerDismisser: UIGestureRecognizerDelegate {