diff --git a/Example/Source/Control/NavigationBaseViewController.swift b/Example/Source/Control/NavigationBaseViewController.swift index b97d433..62acda2 100644 --- a/Example/Source/Control/NavigationBaseViewController.swift +++ b/Example/Source/Control/NavigationBaseViewController.swift @@ -68,7 +68,20 @@ extension NavigationBaseViewController { ]) /* TODO: Support iOS 10 */ /* FIXME: Fix subview's constraints using fluid transition on iOS 10 */ - if #available(iOS 11.0, *) { return } + if #available(iOS 11.0, *) { + switch self.model! { + case .navigationFluidModal, .transitionFluidModal: + break + default: + return + } + } + self.subviewTopConstraint.deactivate() + self.subviewBottomConstraint.deactivate() + self.subviewLeadingConstraint.deactivate() + self.subviewTrailingConstraint.deactivate() + self.closeButtonTopConstraint.deactivate() + self.closeButtonTrailingConstraint.deactivate() switch self.model! { case .navigationFluidModal, .transitionFluidModal: self.subviewTopConstraint = subview.topAnchor.constraint(equalTo: self.view.topAnchor).activate() @@ -77,12 +90,6 @@ extension NavigationBaseViewController { self.subviewTrailingConstraint = subview.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).activate() self.closeButtonTopConstraint = self.closeButton?.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 16).activate() self.closeButtonTrailingConstraint = self.closeButton?.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -16).activate() - subview.removeConstraints([self.subviewTopConstraint, - self.subviewBottomConstraint, - self.subviewLeadingConstraint, - self.subviewTrailingConstraint]) - self.closeButton?.removeConstraints([self.closeButtonTopConstraint, - self.closeButtonTrailingConstraint]) default: subview.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor).activate() subview.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor).activate() diff --git a/Example/Source/Control/NavigationChildViewController.swift b/Example/Source/Control/NavigationChildViewController.swift index d42ee52..fe1f82d 100644 --- a/Example/Source/Control/NavigationChildViewController.swift +++ b/Example/Source/Control/NavigationChildViewController.swift @@ -180,7 +180,7 @@ extension NavigationChildViewController: FluidTransitionDestinationActionDelegat ]) guard transitionStyle.isFluid else { return } switch state { - case .begin: self.navigationController?.setNavigationBarHidden(true, animated: true) + case .begin: break case .update: break case .cancel: break case .end: break diff --git a/Example/Source/Control/NavigationScrollViewController.swift b/Example/Source/Control/NavigationScrollViewController.swift index faba64a..294076f 100644 --- a/Example/Source/Control/NavigationScrollViewController.swift +++ b/Example/Source/Control/NavigationScrollViewController.swift @@ -49,6 +49,14 @@ class NavigationScrollViewController: NavigationBaseViewController, Fluidable { self.headerView.rightAnchor.constraint(equalTo: self.imageContainerView.rightAnchor).activate() self.headerView.heightAnchor.constraint(equalToConstant: self.headerView.estimatedHeight).activate() self.imageView.image = UIImage(row: self.modelIndex, size: .medium) + if #available(iOS 11.0, *) { + switch self.model! { + case .navigationFluidModal, .transitionFluidModal: + self.scrollView.contentInsetAdjustmentBehavior = .never + default: + break + } + } self.scrollView.contentInset.bottom = 40 self.configureConstraints(for: self.scrollView) } diff --git a/Example/Source/Control/RootViewController+Fluidable.swift b/Example/Source/Control/RootViewController+Fluidable.swift index 9effe73..d804267 100644 --- a/Example/Source/Control/RootViewController+Fluidable.swift +++ b/Example/Source/Control/RootViewController+Fluidable.swift @@ -179,8 +179,10 @@ extension RootViewController: FluidTransitionSourceActionDelegate { let navBar: UINavigationBar = self.navigationController?.navigationBar else { return } self.selectedCell?.isHidden = true /* NOTE: If the navigation bar overlaps the cell, hide the navigation bar to avoid that the dismissal animation jumps */ - let cellFrame: CGRect = cell.convert(cell.frame, to: navBar) - if navBar.frame.intersects(cellFrame) { self.navigationController?.setNavigationBarHidden(true, animated: true) } + let referenceView: UIView? = navBar.superview ?? self.view + let cellFrame: CGRect = cell.convert(cell.bounds, to: referenceView) + let navBarFrame: CGRect = navBar.convert(navBar.bounds, to: referenceView) + if navBarFrame.intersects(cellFrame) { self.navigationController?.setNavigationBarHidden(true, animated: true) } case .cancel: self.selectedCell?.isHidden = false case .update, .end: @@ -234,4 +236,3 @@ extension RootViewController: FluidTransitionSourceActionDelegate { state: FluidProgressState, progress: CGFloat, info: FluidGestureInfo) { } } - diff --git a/Example/Source/Control/TransitionBaseViewController.swift b/Example/Source/Control/TransitionBaseViewController.swift index 9e81647..768cc0e 100644 --- a/Example/Source/Control/TransitionBaseViewController.swift +++ b/Example/Source/Control/TransitionBaseViewController.swift @@ -63,7 +63,14 @@ extension TransitionBaseViewController { ]) /* TODO: Support iOS 10 */ /* FIXME: Fix subview's constraints using fluid transition on iOS 10 */ - if #available(iOS 11.0, *) { return } + if #available(iOS 11.0, *) { + switch self.model! { + case .navigationFluidModal, .transitionFluidModal: + break + default: + return + } + } self.subviewTopConstraint.deactivate() self.subviewBottomConstraint.deactivate() self.subviewLeadingConstraint.deactivate() diff --git a/Example/Source/Control/TransitionScrollViewController.swift b/Example/Source/Control/TransitionScrollViewController.swift index 8c20732..1ee9970 100644 --- a/Example/Source/Control/TransitionScrollViewController.swift +++ b/Example/Source/Control/TransitionScrollViewController.swift @@ -53,6 +53,14 @@ class TransitionScrollViewController: TransitionBaseViewController, Fluidable { self.headerView.rightAnchor.constraint(equalTo: self.imageContainerView.rightAnchor).activate() self.headerView.heightAnchor.constraint(equalToConstant: self.headerView.estimatedHeight).activate() self.imageView.image = UIImage(row: self.modelIndex, size: .medium) + if #available(iOS 11.0, *) { + switch self.model! { + case .navigationFluidModal, .transitionFluidModal: + self.scrollView.contentInsetAdjustmentBehavior = .never + default: + break + } + } self.scrollView.contentInset.bottom = 40 self.configureConstraints(for: self.scrollView) } diff --git a/Example/Source/Model/RootModel.swift b/Example/Source/Model/RootModel.swift index da9fa45..29290b8 100644 --- a/Example/Source/Model/RootModel.swift +++ b/Example/Source/Model/RootModel.swift @@ -164,7 +164,7 @@ extension RootModel { var backgroundStyle: FluidBackgroundStyle { switch self { case .navigationFluidModal, .transitionFluidModal: - return .blur(radius: 12, color: .clear, alpha: 1.0) + return .blur(radius: 8, color: .clear, alpha: 1.0) case .navigationFluidFullScreen, .transitionFluidFullScreen: return .dim(color: UIColor.black.withAlphaComponent(0.5)) case .navigationDrawerTop, .navigationDrawerBottom, .navigationDrawerLeft, .navigationDrawerRight, diff --git a/Sources/View/FluidBackgroundView.swift b/Sources/View/FluidBackgroundView.swift index af78713..7b7a0bd 100644 --- a/Sources/View/FluidBackgroundView.swift +++ b/Sources/View/FluidBackgroundView.swift @@ -6,6 +6,7 @@ // Copyright © 2019 Gumob. All rights reserved. // +import CoreImage import Foundation import UIKit @@ -25,24 +26,56 @@ extension FluidBackgroundCompatible where Self: UIView { } } -internal class FluidBlurredBackgroundView: BlurView, FluidBackgroundCompatible { +internal class FluidBlurredBackgroundView: UIView, FluidBackgroundCompatible { + private static let ciContext = CIContext(options: nil) + private static let blurQueue = DispatchQueue(label: "Fluidable.FluidBlurredBackgroundView.blur", qos: .userInitiated) + + private let blurView = UIImageView() + private let tintView = UIView() + private var snapshotSize: CGSize = .zero + private var snapshotGeneration: Int = 0 + private var isSnapshotUpdatePending: Bool = false + var baseBlurRadius: CGFloat = 0 /** A percentage of visibility. */ @objc internal dynamic var visibility: CGFloat = -1 { didSet { guard self.visibility != oldValue else { return } - self.blurRadius = self.baseBlurRadius * self.visibility.clamped(0, 1) + self.applyVisibility() } } + /** Tint color. The default value is nil. */ + @objc dynamic internal var colorTint: UIColor? { + didSet { self.tintView.backgroundColor = self.colorTint } + } + + /** Tint color alpha. The default value is 0.0. */ + @objc dynamic open var colorTintAlpha: CGFloat = 0 { + didSet { self.tintView.alpha = self.colorTintAlpha * self.visibility.clamped(0, 1) } + } + + /** Blur radius. The default value is 0.0. */ + @objc dynamic open var blurRadius: CGFloat = 0 + + /** Scale factor. */ + @objc dynamic open var scale: CGFloat = 1 + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } init(radius: CGFloat, color: UIColor, alpha: CGFloat) { - super.init(effect: nil) + super.init(frame: .zero) + self.clipsToBounds = true + self.blurView.alpha = 0 + self.blurView.contentMode = .scaleToFill + self.addSubview(self.blurView) + self.tintView.alpha = 0 + self.addSubview(self.tintView) self.baseBlurRadius = radius self.blurRadius = 0 self.colorTint = color + self.tintView.backgroundColor = color self.colorTintAlpha = alpha self.visibility = 0 self.isUserInteractionEnabled = false @@ -51,10 +84,119 @@ internal class FluidBlurredBackgroundView: BlurView, FluidBackgroundCompatible { deinit { Logger()?.log("🥶🧹🧹🧹️", []) } + override func didMoveToWindow() { + super.didMoveToWindow() + self.applyVisibility() + } + + override func layoutSubviews() { + super.layoutSubviews() + self.blurView.frame = self.bounds + self.tintView.frame = self.bounds + if self.snapshotSize != self.bounds.size { + self.invalidateSnapshot() + self.applyVisibility() + } + } + override func updateConstraints() { self.fitToSuperview() super.updateConstraints() } + + private func applyVisibility() { + let clampedVisibility = self.visibility.clamped(0, 1) + self.blurRadius = self.baseBlurRadius * clampedVisibility + if clampedVisibility > 0, self.blurView.image == nil { + self.updateSnapshot() + } + self.blurView.alpha = clampedVisibility + self.tintView.alpha = self.colorTintAlpha * clampedVisibility + } + + private func updateSnapshot() { + guard !self.isSnapshotUpdatePending else { return } + guard self.bounds.width > 0, self.bounds.height > 0 else { return } + guard let snapshot = self.makeBackgroundSnapshot() else { return } + + self.snapshotGeneration += 1 + let generation = self.snapshotGeneration + let snapshotSize = self.bounds.size + let blurRadius = self.baseBlurRadius + self.isSnapshotUpdatePending = true + + Self.blurQueue.async { [weak self] in + let blurredImage = Self.makeBlurredImage(from: snapshot, radius: blurRadius) + DispatchQueue.main.async { + guard let self = self else { return } + guard self.snapshotGeneration == generation else { return } + self.isSnapshotUpdatePending = false + guard self.bounds.size == snapshotSize else { return } + self.blurView.image = blurredImage + self.snapshotSize = snapshotSize + } + } + } + + private func invalidateSnapshot() { + self.snapshotGeneration += 1 + self.isSnapshotUpdatePending = false + self.blurView.image = nil + self.snapshotSize = .zero + } + + private func makeBackgroundSnapshot() -> UIImage? { + guard let superview = self.superview, + let backgroundIndex = superview.subviews.firstIndex(of: self) else { return nil } + + let previousSubviews = superview.subviews[.. 0 && view.bounds.width > 1 && view.bounds.height > 1 + } + if hasBackgroundContent { + return self.renderSnapshot(of: superview, hiding: Array(superview.subviews[backgroundIndex...])) + } + if let window = self.window, window !== superview { + return self.renderSnapshot(of: window, hiding: [superview]) + } + return self.renderSnapshot(of: superview, hiding: Array(superview.subviews[backgroundIndex...])) + } + + private func renderSnapshot(of view: UIView, hiding hiddenSubviews: [UIView]) -> UIImage { + let origin = self.convert(CGPoint.zero, to: view) + let hiddenStates = hiddenSubviews.map { $0.isHidden } + hiddenSubviews.forEach { $0.isHidden = true } + defer { + zip(hiddenSubviews, hiddenStates).forEach { view, isHidden in + view.isHidden = isHidden + } + } + + let format = UIGraphicsImageRendererFormat() + format.scale = UIScreen.main.scale + format.opaque = false + let renderer = UIGraphicsImageRenderer(size: self.bounds.size, format: format) + return renderer.image { context in + context.cgContext.translateBy(x: -origin.x, y: -origin.y) + view.layer.render(in: context.cgContext) + } + } + + private static func makeBlurredImage(from image: UIImage, radius: CGFloat) -> UIImage { + guard radius > 0, + let inputImage = CIImage(image: image) else { return image } + + let clampedImage = inputImage.clampedToExtent() + let filter = CIFilter(name: "CIGaussianBlur") + filter?.setValue(clampedImage, forKey: kCIInputImageKey) + filter?.setValue(radius * image.scale, forKey: kCIInputRadiusKey) + + guard let outputImage = filter?.outputImage?.cropped(to: inputImage.extent), + let cgImage = ciContext.createCGImage(outputImage, from: inputImage.extent) else { + return image + } + return UIImage(cgImage: cgImage, scale: image.scale, orientation: image.imageOrientation) + } } internal class FluidDimmedBackgroundView: UIView, FluidBackgroundCompatible { @@ -83,61 +225,3 @@ internal class FluidDimmedBackgroundView: UIView, FluidBackgroundCompatible { super.updateConstraints() } } - -/** - https://github.com/efremidze/VisualEffectView/blob/master/Sources/VisualEffectView.swift - */ -internal class BlurView: UIVisualEffectView { - /** Returns the instance of UIBlurEffect. */ - private let blurEffect: UIBlurEffect = (NSClassFromString("_UICustomBlurEffect") as! UIBlurEffect.Type).init() - - /** Tint color. The default value is nil. */ - @objc dynamic internal var colorTint: UIColor? { - get { return _value(forKey: "colorTint") as? UIColor } - set { _setValue(newValue, forKey: "colorTint") } - } - - /** Tint color alpha. The default value is 0.0. */ - @objc dynamic open var colorTintAlpha: CGFloat { - get { return _value(forKey: "colorTintAlpha") as! CGFloat } - set { _setValue(newValue, forKey: "colorTintAlpha") } - } - - /** Blur radius. The default value is 0.0. */ - @objc dynamic open var blurRadius: CGFloat { - get { return _value(forKey: "blurRadius") as! CGFloat } - set { _setValue(newValue, forKey: "blurRadius") } - } - - /** Scale factor. The scale factor determines how content in the view is mapped from the logical coordinate space (measured in points) to the device coordinate space (measured in pixels). The default value is 1.0. */ - @objc dynamic open var scale: CGFloat { - get { return _value(forKey: "scale") as! CGFloat } - set { _setValue(newValue, forKey: "scale") } - } - - /** Initialization */ - internal override init(effect: UIVisualEffect?) { - super.init(effect: effect) - configure() - } - - required public init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - configure() - } - - private func configure() { - self.scale = 1 - } - - /** Returns the value for the key on the blurEffect. */ - private func _value(forKey key: String) -> Any? { - return blurEffect.value(forKeyPath: key) - } - - /** Sets the value for the key on the blurEffect. */ - private func _setValue(_ value: Any?, forKey key: String) { - blurEffect.setValue(value, forKeyPath: key) - self.effect = blurEffect - } -} diff --git a/Tests/UIKitSpec.swift b/Tests/UIKitSpec.swift index 3b787a2..692342f 100644 --- a/Tests/UIKitSpec.swift +++ b/Tests/UIKitSpec.swift @@ -114,33 +114,6 @@ final class UIKitSpec: QuickSpec { } } - class TestBlurredBackgroundView: FluidBlurredBackgroundView { - var blurRadiusValues: [CGFloat] = [] - var colorTintValues: [UIColor?] = [] - var colorTintAlphaValues: [CGFloat] = [] - var scaleValues: [CGFloat] = [] - - override var blurRadius: CGFloat { - get { return blurRadiusValues.last ?? 0 } - set { blurRadiusValues.append(newValue) } - } - - override var colorTint: UIColor? { - get { return colorTintValues.last ?? nil } - set { colorTintValues.append(newValue) } - } - - override var colorTintAlpha: CGFloat { - get { return colorTintAlphaValues.last ?? 0 } - set { colorTintAlphaValues.append(newValue) } - } - - override var scale: CGFloat { - get { return scaleValues.last ?? 0 } - set { scaleValues.append(newValue) } - } - } - class TestNavigationController: UINavigationController { var name: String? init(rootViewController: UIViewController, name: String) { @@ -1394,12 +1367,20 @@ final class UIKitSpec: QuickSpec { it("fits blurred backgrounds and maps visibility to blur radius") { let container = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 120)) - let backgroundView = TestBlurredBackgroundView(radius: 20, color: .blue, alpha: 0.35) + let window = UIWindow(frame: container.bounds) + let backgroundView = FluidBlurredBackgroundView(radius: 20, color: .blue, alpha: 0.35) + backgroundView.frame = container.bounds + window.addSubview(container) + window.isHidden = false + defer { window.isHidden = true } container.addSubview(backgroundView) + let blurView = backgroundView.subviews.compactMap { $0 as? UIImageView }.first expect(backgroundView.baseBlurRadius).to(beCloseTo(20)) expect(backgroundView.blurRadius).to(beCloseTo(0)) + expect(blurView?.alpha).to(beCloseTo(0)) + expect(blurView?.image).to(beNil()) expect(backgroundView.colorTint).to(equal(.blue)) expect(backgroundView.colorTintAlpha).to(beCloseTo(0.35, within: 0.001)) expect(backgroundView.isUserInteractionEnabled).to(beFalse()) @@ -1407,10 +1388,14 @@ final class UIKitSpec: QuickSpec { backgroundView.visibility = 0.25 expect(backgroundView.blurRadius).to(beCloseTo(5)) + expect(blurView?.alpha).to(beCloseTo(0.25)) + expect(waitForBlurredImage(in: backgroundView)).notTo(beNil()) backgroundView.visibility = -1 expect(backgroundView.blurRadius).to(beCloseTo(0)) + expect(blurView?.alpha).to(beCloseTo(0)) backgroundView.visibility = 2 expect(backgroundView.blurRadius).to(beCloseTo(20)) + expect(blurView?.alpha).to(beCloseTo(1)) backgroundView.scale = 2 expect(backgroundView.scale).to(beCloseTo(2)) @@ -1424,6 +1409,100 @@ final class UIKitSpec: QuickSpec { expect(backgroundView.translatesAutoresizingMaskIntoConstraints).to(beFalse()) expect(attachedConstraints.count).to(equal(4)) } + + it("applies blur to captured background pixels") { + let container = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 120)) + let window = UIWindow(frame: container.bounds) + let redView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 120)) + let blueView = UIView(frame: CGRect(x: 100, y: 0, width: 100, height: 120)) + let backgroundView = FluidBlurredBackgroundView(radius: 20, color: .clear, alpha: 0) + let foregroundView = UIView(frame: container.bounds) + + redView.backgroundColor = .red + blueView.backgroundColor = .blue + foregroundView.backgroundColor = .green + backgroundView.frame = container.bounds + window.addSubview(container) + window.isHidden = false + defer { window.isHidden = true } + container.addSubview(redView) + container.addSubview(blueView) + container.addSubview(backgroundView) + container.addSubview(foregroundView) + + backgroundView.visibility = 1 + + let image = waitForBlurredImage(in: backgroundView) + let boundaryColor = image?.pixelColor(at: CGPoint(x: 100, y: 60)) + + expect(image).notTo(beNil()) + expect(boundaryColor?.red).to(beGreaterThan(0.05)) + expect(boundaryColor?.blue).to(beGreaterThan(0.05)) + } + + it("automatically fits blurred backgrounds after insertion") { + let container = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 120)) + let window = UIWindow(frame: container.bounds) + let sourceView = UIView(frame: container.bounds) + let backgroundView = FluidBlurredBackgroundView(radius: 20, color: .clear, alpha: 0) + + sourceView.backgroundColor = .red + window.addSubview(container) + window.isHidden = false + defer { window.isHidden = true } + container.addSubview(sourceView) + container.addSubview(backgroundView) + + container.updateConstraintsAndLayoutImmediately() + backgroundView.visibility = 1 + + expect(backgroundView.frame).to(equal(container.bounds)) + expect(waitForBlurredImage(in: backgroundView)).notTo(beNil()) + } + + it("captures background behind transition containers") { + let bounds = CGRect(x: 0, y: 0, width: 200, height: 120) + let window = UIWindow(frame: bounds) + let sourceView = UIView(frame: bounds) + let transitionContainerView = UIView(frame: bounds) + let backgroundView = FluidBlurredBackgroundView(radius: 20, color: .clear, alpha: 0) + + sourceView.backgroundColor = .red + window.addSubview(sourceView) + window.addSubview(transitionContainerView) + window.isHidden = false + defer { window.isHidden = true } + transitionContainerView.addSubview(backgroundView) + transitionContainerView.updateConstraintsAndLayoutImmediately() + + backgroundView.visibility = 1 + + let centerColor = waitForBlurredImage(in: backgroundView)?.pixelColor(at: CGPoint(x: 100, y: 60)) + + expect(centerColor?.red).to(beGreaterThan(0.5)) + expect(centerColor?.alpha).to(beGreaterThan(0.5)) + } + + it("renders full screen blur snapshots asynchronously") { + let bounds = CGRect(x: 0, y: 0, width: 402, height: 874) + let window = UIWindow(frame: bounds) + let sourceView = UIView(frame: bounds) + let transitionContainerView = UIView(frame: bounds) + let backgroundView = FluidBlurredBackgroundView(radius: 12, color: .clear, alpha: 1) + + sourceView.backgroundColor = .red + window.addSubview(sourceView) + window.addSubview(transitionContainerView) + window.isHidden = false + defer { window.isHidden = true } + transitionContainerView.addSubview(backgroundView) + transitionContainerView.updateConstraintsAndLayoutImmediately() + + backgroundView.visibility = 1 + + expect(blurredImage(in: backgroundView)).to(beNil()) + expect(waitForBlurredImage(in: backgroundView)).notTo(beNil()) + } } describe("FluidInteractiveView") { it("expands, restores, and resets transforms") { @@ -3190,3 +3269,52 @@ private func seedGesture(_ observer: FluidTransitionGestureObserver, observer.currentVelocity = velocity observer.translationHistory = [averageVector, .zero] } + +private func blurredImage(in backgroundView: FluidBlurredBackgroundView) -> UIImage? { + return backgroundView.subviews.compactMap { $0 as? UIImageView }.first?.image +} + +private func waitForBlurredImage(in backgroundView: FluidBlurredBackgroundView, timeout: TimeInterval = 3) -> UIImage? { + let deadline = CACurrentMediaTime() + timeout + while blurredImage(in: backgroundView) == nil, CACurrentMediaTime() < deadline { + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01)) + } + return blurredImage(in: backgroundView) +} + +private struct TestPixelColor { + let red: CGFloat + let green: CGFloat + let blue: CGFloat + let alpha: CGFloat +} + +private extension UIImage { + func pixelColor(at point: CGPoint) -> TestPixelColor? { + guard let cgImage = self.cgImage else { return nil } + let pixelX = Int(point.x * CGFloat(cgImage.width) / self.size.width) + let pixelY = Int(point.y * CGFloat(cgImage.height) / self.size.height) + guard 0 <= pixelX, pixelX < cgImage.width, + 0 <= pixelY, pixelY < cgImage.height, + let croppedImage = cgImage.cropping(to: CGRect(x: pixelX, y: pixelY, width: 1, height: 1)) else { + return nil + } + + var pixel = [UInt8](repeating: 0, count: 4) + let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue + guard let context = CGContext(data: &pixel, + width: 1, + height: 1, + bitsPerComponent: 8, + bytesPerRow: 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: bitmapInfo) else { + return nil + } + context.draw(croppedImage, in: CGRect(x: 0, y: 0, width: 1, height: 1)) + return TestPixelColor(red: CGFloat(pixel[0]) / 255, + green: CGFloat(pixel[1]) / 255, + blue: CGFloat(pixel[2]) / 255, + alpha: CGFloat(pixel[3]) / 255) + } +} diff --git a/UITests/MainSpec+Transition.swift b/UITests/MainSpec+Transition.swift index 7e56dd4..8fb2942 100644 --- a/UITests/MainSpec+Transition.swift +++ b/UITests/MainSpec+Transition.swift @@ -145,6 +145,21 @@ extension MainSpec { self.assertEventually(!visibleView.exists) } + static func assertRootNavigationVisibleAndFluidModalContentAligned(app: XCUIApplication, model: RootModel) { + let optionButton = app.buttons["Option"] + self.assertEventually(optionButton.exists) + + let visibleView: XCUIElement = app.otherElements.element(matching: .other, identifier: model.visibleControllerViewAccessibilityIdentifier) + let scrollView: XCUIElement = app.scrollViews.element(matching: .scrollView, identifier: model.parentScrollViewAccessibilityIdentifier) + let scrollTopView: XCUIElement = app.otherElements.element(matching: .other, identifier: model.parentScrollTopViewAccessibilityIdentifier) + self.assertEventually(visibleView.exists) + self.assertEventually(scrollView.exists) + self.assertEventually(scrollTopView.exists) + + XCTAssertEqual(scrollView.frame.minY, visibleView.frame.minY, accuracy: 2) + XCTAssertEqual(scrollTopView.frame.minY, scrollView.frame.minY, accuracy: 2) + } + static func pushViewController(app: XCUIApplication, orientation: UIDeviceOrientation, model: RootModel) { switch model.className { case "NavigationCollectionViewController": diff --git a/UITests/MainSpec.swift b/UITests/MainSpec.swift index 23271bd..433ca1d 100644 --- a/UITests/MainSpec.swift +++ b/UITests/MainSpec.swift @@ -87,6 +87,12 @@ final class MainSpec: QuickSpec { self.popViewControllerByTappingBackButton(app: app, orientation: orientation, model: model) self.finishAnimatedDismissByTappingContainer(app: app, orientation: orientation, model: model) } + if orientation == .portrait && model == .navigationFluidModal { + it("KeepRootNavigationVisibleAndAlignFluidModalContent") { + self.finishAnimatedPresent(app: app, orientation: orientation, model: model) + self.assertRootNavigationVisibleAndFluidModalContentAligned(app: app, model: model) + } + } /* FIXME: No way to perform interruptible transition */ // it("FinishAnimatedPresent_CancelInteractiveDismiss") { // self.finishAnimatedPresent(app: app, orientation: orientation, model: model)