| /* |
| 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 XCTest |
| #if IS_BAZEL_BUILD |
| import MotionAnimator |
| #else |
| import MotionAnimator |
| #endif |
| |
| class ShapeLayerBackedView: UIView { |
| override static var layerClass: AnyClass { return CAShapeLayer.self } |
| |
| override init(frame: CGRect) { |
| super.init(frame: frame) |
| |
| let shapeLayer = self.layer as! CAShapeLayer |
| shapeLayer.path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100)).cgPath |
| } |
| |
| required init?(coder aDecoder: NSCoder) { |
| fatalError("init(coder:) has not been implemented") |
| } |
| } |
| |
| class UIKitBehavioralTests: XCTestCase { |
| var view: UIView! |
| var window: UIWindow! |
| |
| override func setUp() { |
| super.setUp() |
| |
| window = getTestHarnessKeyWindow() |
| |
| rebuildView() |
| } |
| |
| override func tearDown() { |
| view = nil |
| window = nil |
| |
| super.tearDown() |
| } |
| |
| private func rebuildView() { |
| window.subviews.forEach { $0.removeFromSuperview() } |
| view = ShapeLayerBackedView() // Need to animate a view's layer to get implicit animations. |
| view.layer.anchorPoint = .zero |
| window.addSubview(view) |
| |
| // Connect our layers to the render server. |
| CATransaction.flush() |
| } |
| |
| // MARK: Each animatable property needs to be added to exactly one of the following three tests |
| |
| func testSomePropertiesImplicitlyAnimateAdditively() { |
| let baselineProperties: [AnimatableKeyPath: Any] = [ |
| .bounds: CGRect(x: 0, y: 0, width: 123, height: 456), |
| .height: 100, |
| .position: CGPoint(x: 50, y: 20), |
| .rotation: 42, |
| .scale: 2.5, |
| // Note: prior to iOS 14 this used to work as a CGAffineTransform. iOS 14 now only accepts |
| // CATransform3D instances when using KVO. |
| .transform: CATransform3DMakeScale(1.5, 1.5, 1.5), |
| .width: 25, |
| .x: 12, |
| .y: 23, |
| ] |
| let properties: [AnimatableKeyPath: Any] |
| if #available(iOS 11.0, *) { |
| // Corner radius became implicitly animatable in iOS 11. |
| var baselineWithCornerRadiusProperties = baselineProperties |
| baselineWithCornerRadiusProperties[.cornerRadius] = 3 |
| properties = baselineWithCornerRadiusProperties |
| } else { |
| properties = baselineProperties |
| } |
| for (keyPath, value) in properties { |
| rebuildView() |
| |
| UIView.animate(withDuration: 0.01) { |
| self.view.layer.setValue(value, forKeyPath: keyPath.rawValue) |
| } |
| |
| XCTAssertNotNil(view.layer.animationKeys(), |
| "Expected \(keyPath.rawValue) to generate at least one animation.") |
| if let animationKeys = view.layer.animationKeys() { |
| for key in animationKeys { |
| let animation = view.layer.animation(forKey: key) as! CABasicAnimation |
| XCTAssertTrue(animation.isAdditive, |
| "Expected \(key) to be additive as a result of animating " |
| + "\(keyPath.rawValue), but it was not: \(animation.debugDescription).") |
| } |
| } |
| } |
| } |
| |
| func testSomePropertiesImplicitlyAnimateButNotAdditively() { |
| let baselineProperties: [AnimatableKeyPath: Any] = [ |
| .backgroundColor: UIColor.blue, |
| .opacity: 0.5, |
| ] |
| let properties: [AnimatableKeyPath: Any] |
| if #available(iOS 11.0, *) { |
| // Anchor point became implicitly animatable in iOS 11. |
| var baselineWithCornerRadiusProperties = baselineProperties |
| baselineWithCornerRadiusProperties[.anchorPoint] = CGPoint(x: 0.2, y: 0.4) |
| properties = baselineWithCornerRadiusProperties |
| } else { |
| properties = baselineProperties |
| } |
| for (keyPath, value) in properties { |
| rebuildView() |
| |
| UIView.animate(withDuration: 0.01) { |
| self.view.layer.setValue(value, forKeyPath: keyPath.rawValue) |
| } |
| |
| XCTAssertNotNil(view.layer.animationKeys(), |
| "Expected \(keyPath.rawValue) to generate at least one animation.") |
| if let animationKeys = view.layer.animationKeys() { |
| for key in animationKeys { |
| let animation = view.layer.animation(forKey: key) as! CABasicAnimation |
| XCTAssertFalse(animation.isAdditive, |
| "Expected \(key) not to be additive as a result of animating " |
| + "\(keyPath.rawValue), but it was: \(animation.debugDescription).") |
| } |
| } |
| } |
| } |
| |
| func testSomePropertiesDoNotImplicitlyAnimate() { |
| let baselineProperties: [AnimatableKeyPath: Any] = [ |
| .anchorPoint: CGPoint(x: 0.2, y: 0.4), |
| .borderWidth: 5, |
| .borderColor: UIColor.red, |
| .cornerRadius: 3, |
| .shadowColor: UIColor.blue, |
| .shadowOffset: CGSize(width: 1, height: 1), |
| .shadowOpacity: 0.3, |
| .shadowRadius: 5, |
| .strokeStart: 0.2, |
| .strokeEnd: 0.5, |
| .z: 3, |
| ] |
| |
| let properties: [AnimatableKeyPath: Any] |
| if #available(iOS 13, *) { |
| // Shadow opacity became implicitly animatable in iOS 13. |
| var baselineWithModernSupport = baselineProperties |
| baselineWithModernSupport.removeValue(forKey: .shadowOpacity) |
| baselineWithModernSupport.removeValue(forKey: .anchorPoint) |
| baselineWithModernSupport.removeValue(forKey: .cornerRadius) |
| properties = baselineWithModernSupport |
| |
| } else if #available(iOS 11.0, *) { |
| // Corner radius became implicitly animatable in iOS 11. |
| var baselineWithModernSupport = baselineProperties |
| baselineWithModernSupport.removeValue(forKey: .anchorPoint) |
| baselineWithModernSupport.removeValue(forKey: .cornerRadius) |
| properties = baselineWithModernSupport |
| |
| } else { |
| properties = baselineProperties |
| } |
| |
| for (keyPath, value) in properties { |
| rebuildView() |
| |
| UIView.animate(withDuration: 0.01) { |
| self.view.layer.setValue(value, forKeyPath: keyPath.rawValue) |
| } |
| |
| XCTAssertNil(view.layer.animationKeys(), |
| "Expected \(keyPath.rawValue) not to generate any animations.") |
| } |
| } |
| |
| // MARK: Every animatable layer property must be added to the following test |
| |
| func testNoPropertiesImplicitlyAnimateOutsideOfAnAnimationBlock() { |
| let properties: [AnimatableKeyPath: Any] = [ |
| .backgroundColor: UIColor.blue, |
| .bounds: CGRect(x: 0, y: 0, width: 123, height: 456), |
| .cornerRadius: 3, |
| .height: 100, |
| .opacity: 0.5, |
| .position: CGPoint(x: 50, y: 20), |
| .rotation: 42, |
| .scale: 2.5, |
| .shadowColor: UIColor.blue, |
| .shadowOffset: CGSize(width: 1, height: 1), |
| .shadowOpacity: 0.3, |
| .shadowRadius: 5, |
| .strokeStart: 0.2, |
| .strokeEnd: 0.5, |
| .transform: CGAffineTransform(scaleX: 1.5, y: 1.5), |
| .width: 25, |
| .x: 12, |
| .y: 23, |
| ] |
| for (keyPath, value) in properties { |
| rebuildView() |
| |
| self.view.layer.setValue(value, forKeyPath: keyPath.rawValue) |
| |
| XCTAssertNil(view.layer.animationKeys(), |
| "Expected \(keyPath.rawValue) not to generate any animations.") |
| } |
| } |
| |
| // MARK: .beginFromCurrentState option behavior |
| // |
| // The following tests indicate that UIKit treats .beginFromCurrentState differently depending |
| // on the key path being animated. This difference is in line with whether or not a key path is |
| // animated additively or not. |
| // |
| // > See testSomePropertiesImplicitlyAnimateAdditively and |
| // > testSomePropertiesImplicitlyAnimateButNotAdditively for a list of which key paths are |
| // > animated which way. |
| // |
| // Notably, ONLY non-additive key paths are affected by the beginFromCurrentState option. This |
| // likely became the case starting in iOS 8 when additive animations were enabled by default. |
| // Additive animations will always animate additively regardless of whether or not you provide |
| // this flag. |
| |
| func testDefaultsAnimatesOpacityNonAdditivelyFromItsModelLayerState() { |
| UIView.animate(withDuration: 0.1) { |
| self.view.alpha = 0.5 |
| } |
| |
| RunLoop.main.run(until: .init(timeIntervalSinceNow: 0.01)) |
| |
| let initialValue = self.view.layer.opacity |
| |
| UIView.animate(withDuration: 0.1) { |
| self.view.alpha = 0.2 |
| } |
| |
| XCTAssertNotNil(view.layer.animationKeys(), |
| "Expected an animation to be added, but none were found.") |
| guard let animationKeys = view.layer.animationKeys() else { |
| return |
| } |
| XCTAssertEqual(animationKeys.count, 1, |
| "Expected only one animation to be added, but the following were found: " |
| + "\(animationKeys).") |
| guard let key = animationKeys.first, |
| let animation = view.layer.animation(forKey: key) as? CABasicAnimation else { |
| return |
| } |
| |
| XCTAssertFalse(animation.isAdditive, "Expected the animation not to be additive, but it was.") |
| |
| XCTAssertTrue(animation.fromValue is Float, |
| "The animation's from value was not a number type: " |
| + String(describing: animation.fromValue)) |
| guard let fromValue = animation.fromValue as? Float else { |
| return |
| } |
| |
| XCTAssertEqual(fromValue, initialValue, accuracy: 0.0001, |
| "Expected the animation to start from \(initialValue), but it did not.") |
| } |
| |
| func testBeginFromCurrentStateAnimatesOpacityNonAdditivelyFromItsPresentationLayerState() { |
| UIView.animate(withDuration: 0.1) { |
| self.view.alpha = 0.5 |
| } |
| |
| RunLoop.main.run(until: .init(timeIntervalSinceNow: 0.01)) |
| |
| let initialValue = self.view.layer.presentation()!.opacity |
| |
| UIView.animate(withDuration: 0.1, delay: 0, options: .beginFromCurrentState, animations: { |
| self.view.alpha = 0.2 |
| }, completion: nil) |
| |
| XCTAssertNotNil(view.layer.animationKeys(), |
| "Expected an animation to be added, but none were found.") |
| guard let animationKeys = view.layer.animationKeys() else { |
| return |
| } |
| XCTAssertEqual(animationKeys.count, 1, |
| "Expected only one animation to be added, but the following were found: " |
| + "\(animationKeys).") |
| guard let key = animationKeys.first, |
| let animation = view.layer.animation(forKey: key) as? CABasicAnimation else { |
| return |
| } |
| |
| XCTAssertFalse(animation.isAdditive, "Expected the animation not to be additive, but it was.") |
| |
| XCTAssertTrue(animation.fromValue is Float, |
| "The animation's from value was not a number type: " |
| + String(describing: animation.fromValue)) |
| guard let fromValue = animation.fromValue as? Float else { |
| return |
| } |
| |
| XCTAssertEqual(fromValue, initialValue, accuracy: 0.0001, |
| "Expected the animation to start from \(initialValue), but it did not.") |
| } |
| |
| func testDefaultsAnimatesPositionAdditivelyFromItsModelLayerState() { |
| UIView.animate(withDuration: 0.1) { |
| self.view.layer.position = CGPoint(x: 100, y: self.view.layer.position.y) |
| } |
| |
| RunLoop.main.run(until: .init(timeIntervalSinceNow: 0.01)) |
| |
| let initialValue = self.view.layer.position |
| |
| UIView.animate(withDuration: 0.1) { |
| self.view.layer.position = CGPoint(x: 20, y: self.view.layer.position.y) |
| } |
| |
| let displacement = initialValue.x - self.view.layer.position.x |
| |
| XCTAssertNotNil(view.layer.animationKeys(), |
| "Expected an animation to be added, but none were found.") |
| guard let animationKeys = view.layer.animationKeys() else { |
| return |
| } |
| XCTAssertEqual(animationKeys.count, 2, |
| "Expected two animations to be added, but the following were found: " |
| + "\(animationKeys).") |
| guard let key = animationKeys.first(where: { $0 != "position" }), |
| let animation = view.layer.animation(forKey: key) as? CABasicAnimation else { |
| return |
| } |
| |
| XCTAssertTrue(animation.isAdditive, "Expected the animation to be additive, but it wasn't.") |
| |
| XCTAssertTrue(animation.fromValue is CGPoint, |
| "The animation's from value was not a point type: " |
| + String(describing: animation.fromValue)) |
| guard let fromValue = animation.fromValue as? CGPoint else { |
| return |
| } |
| |
| XCTAssertEqual(fromValue.x, displacement, accuracy: 0.0001, |
| "Expected the animation to have a delta of \(displacement), but it did not.") |
| } |
| |
| func testBeginFromCurrentStateAnimatesPositionAdditivelyFromItsModelLayerState() { |
| UIView.animate(withDuration: 0.1) { |
| self.view.layer.position = CGPoint(x: 100, y: self.view.layer.position.y) |
| } |
| |
| RunLoop.main.run(until: .init(timeIntervalSinceNow: 0.01)) |
| |
| let initialValue = self.view.layer.position |
| |
| UIView.animate(withDuration: 0.1, delay: 0, options: .beginFromCurrentState, animations: { |
| self.view.layer.position = CGPoint(x: 20, y: self.view.layer.position.y) |
| }, completion: nil) |
| |
| let displacement = initialValue.x - self.view.layer.position.x |
| |
| XCTAssertNotNil(view.layer.animationKeys(), |
| "Expected an animation to be added, but none were found.") |
| guard let animationKeys = view.layer.animationKeys() else { |
| return |
| } |
| XCTAssertEqual(animationKeys.count, 2, |
| "Expected two animations to be added, but the following were found: " |
| + "\(animationKeys).") |
| guard let key = animationKeys.first(where: { $0 != "position" }), |
| let animation = view.layer.animation(forKey: key) as? CABasicAnimation else { |
| return |
| } |
| |
| XCTAssertTrue(animation.isAdditive, "Expected the animation to be additive, but it wasn't.") |
| |
| XCTAssertTrue(animation.fromValue is CGPoint, |
| "The animation's from value was not a point type: " |
| + String(describing: animation.fromValue)) |
| guard let fromValue = animation.fromValue as? CGPoint else { |
| return |
| } |
| |
| XCTAssertEqual(fromValue.x, displacement, accuracy: 0.0001, |
| "Expected the animation to have a delta of \(displacement), but it did not.") |
| } |
| |
| } |