Using UIPercentDrivenInteractiveTransition with CABasicAnimation has weird glitch - swift

I'm implementing custom transition using CABasicAnimation and UIView.animate both. Also need to implement a custom interactive transition using UIPercentDrivenInteractiveTransition which exactly copies the behavior of the native iOS swipe back. Animation without a back swipe gesture (when I'm pushing and popping by the back arrow) works fine and smoothly. Moreover, swipe back also works smoothly, except when the gesture velocity is more than 900
Gesture Recognition function:
#objc func handleBackGesture(_ gesture: UIScreenEdgePanGestureRecognizer) {
guard animationTransition != nil else { return }
switch gesture.state {
case .began:
interactionController = TransparentNavigationControllerTransitionInteractor(duration: anumationDuration)
popViewController(animated: true)
case .changed:
guard let view = gesture.view?.superview else { return }
let translation = gesture.translation(in: view)
var percentage = translation.x / view.bounds.size.width
percentage = min(1.0, max(0.0, percentage))
shouldCompleteTransition = percentage > 0.5
interactionController?.update(percentage)
case .cancelled, .failed, .possible:
if let interactionController = self.interactionController {
isInteractiveStarted = false
interactionController.cancel()
}
case .ended:
interactionController?.completionSpeed = 0.999
let greaterThanMaxVelocity = gesture.velocity(in: view).x > 800
let canFinish = shouldCompleteTransition || greaterThanMaxVelocity
canFinish ? interactionController?.finish() : interactionController?.cancel()
interactionController = nil
#unknown default: assertionFailure()
}
}
UIPercentDrivenInteractiveTransition class. Here I'm synchronizing layer animation.
final class TransparentNavigationControllerTransitionInteractor: UIPercentDrivenInteractiveTransition {
// MARK: - Private Properties
private var context: UIViewControllerContextTransitioning?
private var pausedTime: CFTimeInterval = 0
private let animationDuration: TimeInterval
// MARK: - Initialization
init(duration: TimeInterval) {
self.animationDuration = duration * 0.4 // I dk why but layer duration should be less
super.init()
}
// MARK: - Public Methods
override func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
super.startInteractiveTransition(transitionContext)
context = transitionContext
pausedTime = transitionContext.containerView.layer.convertTime(CACurrentMediaTime(), from: nil)
transitionContext.containerView.layer.speed = 0
transitionContext.containerView.layer.timeOffset = pausedTime
}
override func finish() {
restart(isFinishing: true)
super.finish()
}
override func cancel() {
restart(isFinishing: false)
super.cancel()
}
override func update(_ percentComplete: CGFloat) {
super.update(percentComplete)
guard let transitionContext = context else { return }
let progress = CGFloat(animationDuration) * percentComplete
transitionContext.containerView.layer.timeOffset = pausedTime + Double(progress)
}
// MARK: - Private Methods
private func restart(isFinishing: Bool) {
guard let transitionLayer = context?.containerView.layer else { return }
transitionLayer.beginTime = transitionLayer.convertTime(CACurrentMediaTime(), from: nil)
transitionLayer.speed = isFinishing ? 1 : -1
}
}
And here is my Dismissal animation function in UIViewControllerAnimatedTransitioning class
private func runDismissAnimationFrom(
_ fromView: UIView,
to toView: UIView,
in transitionContext: UIViewControllerContextTransitioning) {
guard let toViewController = transitionContext.viewController(forKey: .to) else { return }
toView.frame = toView.frame.offsetBy(dx: -fromView.frame.width / 3, dy: 0)
let toViewFinalFrame = transitionContext.finalFrame(for: toViewController)
let fromViewFinalFrame = fromView.frame.offsetBy(dx: fromView.frame.width, dy: 0)
// Create mask to hide bottom view with sliding
let slidingMask = CAShapeLayer()
let initialMaskPath = UIBezierPath(rect: CGRect(
x: fromView.frame.width / 3,
y: 0,
width: 0,
height: toView.frame.height)
)
let finalMaskPath = UIBezierPath(rect: toViewFinalFrame)
slidingMask.path = initialMaskPath.cgPath
toView.layer.mask = slidingMask
toView.alpha = 0
let slidingAnimation = CABasicAnimation(keyPath: "path")
slidingAnimation.fromValue = initialMaskPath.cgPath
slidingAnimation.toValue = finalMaskPath.cgPath
slidingAnimation.timingFunction = .init(name: .linear)
slidingMask.path = finalMaskPath.cgPath
slidingMask.add(slidingAnimation, forKey: slidingAnimation.keyPath)
UIView.animate(
withDuration: duration,
delay: 0,
options: animationOptions,
animations: {
fromView.frame = fromViewFinalFrame
toView.frame = toViewFinalFrame
toView.alpha = 1
},
completion: { _ in
toView.layer.mask = nil
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
I note that glitch occurs only when a swipe has a grand velocity.
Here a video with the result of smooth animation at normal speed and not smooth at high speed - https://youtu.be/1d-kTPlhNvE
UPD:
I've already tried to use UIViewPropertyAnimator combine with
interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating
But the result is another type of glitching.

I've solved the issue, just change a part of restart function:
transitionLayer.beginTime =
transitionLayer.convertTime(CACurrentMediaTime(), from: nil) - transitionLayer.timeOffset
transitionLayer.speed = 1
I don't really understand why, but looks like timeOffset subtraction works!

Related

Navigation view transition full screen to a view with corner radius

I am trying to create an app home screen animation from splash, like after launch screen completed (full)screen color transforms into an app logo background color. Currently below code kind of archive what I expected. But, that transformation CAShapeLayer doesn't do with corner radius. Without corner radius it works as normal, when I try to use circle/oval/corner radius animation seems like below gif.
Tried few other StackOverflow answers which create circle animation those are not working. Here one of those.
weak var viewTransitionContext: UIViewControllerContextTransitioning!
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
viewTransitionContext = transitionContext
guard let fromVC = viewTransitionContext.viewController(forKey: .from) else { return }
guard let toVC = viewTransitionContext.viewController(forKey: .to) else { return }
if fromVC.isKind(of: SOGSplashViewController.self) && toVC.isKind(of: SOGHomeViewController.self) {
guard let toVCView = transitionContext.view(forKey: .to) else { return }
guard let fromVCView = transitionContext.view(forKey: .from) else { return }
let containerView = transitionContext.containerView
let labelWidth = UIDevice.width() * 0.75
let labelHeight = labelWidth * 0.7
let xAxis = (UIDevice.width() - labelWidth)/2.0
let yAxis = ((UIDevice.height()/2.0) - labelHeight)/2.0
let labelRect = CGRect(x: xAxis, y: yAxis, width: labelWidth, height: labelHeight)
let radius = (UIDevice.height()/2.0)*0.1
let fromFrame = fromVCView.bounds
let animationTime = transitionDuration(using: transitionContext)
let maskLayer = CAShapeLayer()
maskLayer.isOpaque = false
maskLayer.fillColor = fromVCView.backgroundColor?.cgColor
maskLayer.backgroundColor = UIColor.clear.cgColor
maskLayer.path = toPathValue.cgPath
let maskAnimationLayer = CABasicAnimation(keyPath: "path")
maskAnimationLayer.fromValue = (UIBezierPath(rect: fromFrame)).cgPath
maskAnimationLayer.toValue = toPathValue.cgPath
maskAnimationLayer.duration = animationTime
maskAnimationLayer.delegate = self as? CAAnimationDelegate
containerView.addSubview(fromVCView)
containerView.addSubview(toVCView)
fromVCView.layer.add(maskAnimationLayer, forKey: nil)
maskLayer.add(maskAnimationLayer, forKey: "path")
containerView.layer.addSublayer(maskLayer)
let deadLineTime = DispatchTime.now() + .seconds(1)
DispatchQueue.main.asyncAfter(deadline: deadLineTime) {
UIView.animate(withDuration: 0.2, animations: {
maskLayer.opacity = 0
}, completion: { (isSuccess) in
self.viewTransitionContext.completeTransition(true)
})
}
}
}
Transforming a rectangular path to a rounded rectangular path is a very complex operation if you do it through a generic way like Core Animation.. You should better use the cornerRadius property of CALayer which is animatable.
Here is a working example with a constraint based animation:
class ViewController: UIViewController {
#IBOutlet var constraints: [NSLayoutConstraint]!
#IBOutlet var contentView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewDidAppear(_ animated: Bool) {
self.contentView.layer.cornerRadius = 10.0
self.animate(nil)
}
#IBAction func animate(_ sender: Any?) {
for c in constraints {
c.constant = 40.0
}
UIView.animate(withDuration: 4.0) {
self.view.layoutIfNeeded()
self.contentView.layer.cornerRadius = 40.0
}
}
}
contentView points to the inner view which should be animated, and constraints refer to the four layout constraints defining the distances from the view controller's view to the content view.
This is just a simple, rough example, which can certainly be improved.

Detect intersect with masked image (not rectangular)

How to not get detected with intersects func when moving around masked image in UIImageView frame?
explanation image
Code that I am using to detect collision:
movingPerson.frame.intersects(camera.frame)
Where movingPerson is just a regular UIImage that I am moving around with touchesmoved func and camera is a masked Image.
I've tried .bounds instead of .frame but it's not working.
Is there any easy way?
If you want the "Very easy way" then UIDynamicItem and provide a path that bounds your image and let UIDynamicAnimator handle the collisions for you. If you do not know how to get the path for your artwork (yours is pretty easy and you should be able to extract the Bezier coordinates automatically in Paintcode, by hand in Illustrator, or from a SVG file) you can use SpriteKit instead since it will actually generate the bounding polygon for your sprite automatically.
EDIT Sample (note I didn't want to write a whole app for this so I took an existing playground and just added the colliders. the collision works, but the reset after collision doesn't):
import UIKit
import PlaygroundSupport
class ObstacleView: UIView {
override var collisionBoundsType: UIDynamicItemCollisionBoundsType {
return .path
}
override var collisionBoundingPath: UIBezierPath {
return UIBezierPath(ovalIn: bounds)
}
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = bounds.size.width / 2
}
}
class ViewController: UIViewController {
var dynamicAnimator: UIDynamicAnimator!
var gravityBehavior: UIGravityBehavior!
var pushBehavior: UIPushBehavior!
var collisionBehavior: UICollisionBehavior!
lazy var player: UILabel = {
let label = UILabel()
label.text = "🐥"
label.sizeToFit()
label.isUserInteractionEnabled = true
label.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(label)
return label
}()
var isPanning = false
var anchor: CGPoint = .zero
let maxDistance = CGFloat(100)
lazy var obstacles: [UIView] = {
return (0..<2).map { index in
let view = ObstacleView(frame: CGRect(x:100 + CGFloat(index)*200,y:200, width: 40, height: 40))
view.backgroundColor = .blue
view.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(view)
return view
}
}()
override func viewDidLoad() {
super.viewDidLoad()
dynamicAnimator = UIDynamicAnimator(referenceView: view)
dynamicAnimator.delegate = self
gravityBehavior = UIGravityBehavior(items: [player])
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(pan))
_ = obstacles
view.addGestureRecognizer(panGestureRecognizer)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
anchor = view.center
player.center = anchor
}
#objc private func pan(recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
guard player.bounds.contains(recognizer.location(in: player)) else {
isPanning = false
return
}
isPanning = true
case .changed:
guard isPanning else {
return
}
player.center = recognizer.location(in: view)
let dx = player.center.x - anchor.x
let dy = player.center.y - anchor.y
let distance = CGFloat(hypotf(Float(dx), Float(dy)))
if distance > maxDistance {
player.center.x = anchor.x + dx / distance * maxDistance
player.center.y = anchor.y + dy / distance * maxDistance
}
case .ended:
guard isPanning else {
return
}
isPanning = false
let dx = player.center.x - anchor.x
let dy = player.center.y - anchor.y
let distance = CGFloat(hypotf(Float(dx), Float(dy)))
guard distance > 10 else {
player.center = anchor
return
}
if pushBehavior != nil {
dynamicAnimator.removeBehavior(pushBehavior)
}
pushBehavior = UIPushBehavior(items: [player], mode: .instantaneous)
pushBehavior.pushDirection = CGVector(dx: -dx, dy: -dy)
pushBehavior.magnitude = distance / maxDistance * 0.75
dynamicAnimator.addBehavior(pushBehavior)
dynamicAnimator.addBehavior(gravityBehavior)
collisionBehavior = UICollisionBehavior(items: [player] + obstacles)
collisionBehavior.translatesReferenceBoundsIntoBoundary = true
collisionBehavior.collisionDelegate = self
dynamicAnimator.addBehavior(collisionBehavior)
case .cancelled:
isPanning = false
player.center = anchor
default:
break
}
}
}
extension ViewController: UIDynamicAnimatorDelegate {
func dynamicAnimatorDidPause(_ animator: UIDynamicAnimator) {
dynamicAnimator.removeAllBehaviors()
player.center = anchor
}
}
extension ViewController: UICollisionBehaviorDelegate {
func collisionBehavior(_ behavior: UICollisionBehavior, beganContactFor item1: UIDynamicItem, with item2: UIDynamicItem, at p: CGPoint) {
print("contact")
}
}
PlaygroundPage.current.liveView = ViewController()
PlaygroundPage.current.needsIndefiniteExecution = true

Issue in blocking Vertical scroll on UIScrollView in Swift 4.0

I have an Image carousel in my app I use a UIScrollView to show the images inside. everything works fine, it's just that I want to know how do I block up movements in the UIScrollView
I'm trying to block the vertical scroll by doing:
scrollView.showsVerticalScrollIndicator = false
scrollView.contentSize = CGSize(width: scrollView.contentSize.width, height: 0) //disable vertical
everything in that works fine and it really blocks the vertical scroll
The problem is,
that I also have a timer, that moves the UIScrollView programmatically by doing:
var frame: CGRect = scrollView.frame
frame.origin.x = frame.size.width * CGFloat(pageToMove)
frame.origin.y = -35
scrollView.scrollRectToVisible(frame, animated: true)
and once I block the vertical scroll,
this function to scrollReactToVisible doesn't do anything.
and I don't get any error for that.
is there a way currently to also block the scroll vertically (and allow to scroll right and left as usual) and also move the scrollview programmatically?
I'm attaching my full view controller:
class CaruselleScreenViewController: UIViewController, CaruselleScreenViewProtocol, UIScrollViewDelegate {
var myPresenter: CaruselleScreenPresenterProtocol?
#IBOutlet weak var pageControl: UIPageControl!
#IBOutlet weak var scrollView: UIScrollView!
var slides:[CaruselleTipsCard] = [];
var timer:Timer?
var currentPageMultiplayer = 0
override func viewDidLoad() {
super.viewDidLoad()
myPresenter = CaruselleScreenPresenter(controller: self)
//initlizes view
pageControl.numberOfPages = slides.count
pageControl.currentPage = 0
view.bringSubview(toFront: pageControl)
//delegates
scrollView.delegate = self
////blocks vertical movement
scrollView.showsVerticalScrollIndicator = false
//scrollView.contentSize = CGSize(width: scrollView.contentSize.width, height: 0) //disable vertical
}
func scheduleTimer(_ timeInterval: TimeInterval){
timer = Timer.scheduledTimer(timeInterval: timeInterval, target: self, selector: #selector(timerCall), userInfo: nil, repeats: false)
}
#objc func timerCall(){
print("Timer executed")
currentPageMultiplayer = currentPageMultiplayer + 1
if (currentPageMultiplayer == 5) {
currentPageMultiplayer = 0
}
pageControl.currentPage = currentPageMultiplayer
scrollToPage(pageToMove: currentPageMultiplayer)
scheduleTimer(5)
}
func scrollToPage(pageToMove: Int) {
print ("new one")
var frame: CGRect = scrollView.frame
frame.origin.x = frame.size.width * CGFloat(pageToMove)
frame.origin.y = -35
scrollView.scrollRectToVisible(frame, animated: true)
}
func createSlides() -> [CaruselleTipsCard] {
let slide1:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
slide1.mainPic.image = UIImage(named: "backlightingIllo")
//
let slide2:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
slide2.mainPic.image = UIImage(named: "comfortableIllo")
//
let slide3:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
slide3.mainPic.image = UIImage(named: "pharmacyIllo")
//
let slide4:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
slide4.mainPic.image = UIImage(named: "batteryIllo")
//
let slide5:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
slide5.mainPic.image = UIImage(named: "wiFiIllo")
return [slide1, slide2, slide3, slide4, slide5]
}
func setupSlideScrollView(slides : [CaruselleTipsCard]) {
scrollView.frame = CGRect(x: 0, y: 0, width: view.frame.width, height: view.frame.height)
scrollView.contentSize = CGSize(width: view.frame.width * CGFloat(slides.count), height: view.frame.height)
scrollView.isPagingEnabled = true
for i in 0 ..< slides.count {
slides[i].frame = CGRect(x: view.frame.width * CGFloat(i), y: 0, width: view.frame.width, height: view.frame.height)
scrollView.addSubview(slides[i])
}
}
//////
/*
* default function called when view is scrolled. In order to enable callback
* when scrollview is scrolled, the below code needs to be called:
* slideScrollView.delegate = self or
*/
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let pageIndex = round(scrollView.contentOffset.x/view.frame.width)
pageControl.currentPage = Int(pageIndex)
let maximumHorizontalOffset: CGFloat = scrollView.contentSize.width - scrollView.frame.width
let currentHorizontalOffset: CGFloat = scrollView.contentOffset.x
// vertical
let maximumVerticalOffset: CGFloat = scrollView.contentSize.height - scrollView.frame.height
let currentVerticalOffset: CGFloat = scrollView.contentOffset.y
let percentageHorizontalOffset: CGFloat = currentHorizontalOffset / maximumHorizontalOffset
let percentageVerticalOffset: CGFloat = currentVerticalOffset / maximumVerticalOffset
/*
* below code changes the background color of view on paging the scrollview
*/
// self.scrollView(scrollView, didScrollToPercentageOffset: percentageHorizontalOffset)
/*
* below code scales the imageview on paging the scrollview
*/
let percentOffset: CGPoint = CGPoint(x: percentageHorizontalOffset, y: percentageVerticalOffset)
if(percentOffset.x > 0 && percentOffset.x <= 0.25) {
slides[0].mainPic.transform = CGAffineTransform(scaleX: (0.25-percentOffset.x)/0.25, y: (0.25-percentOffset.x)/0.25)
slides[1].mainPic.transform = CGAffineTransform(scaleX: percentOffset.x/0.25, y: percentOffset.x/0.25)
} else if(percentOffset.x > 0.25 && percentOffset.x <= 0.50) {
slides[1].mainPic.transform = CGAffineTransform(scaleX: (0.50-percentOffset.x)/0.25, y: (0.50-percentOffset.x)/0.25)
slides[2].mainPic.transform = CGAffineTransform(scaleX: percentOffset.x/0.50, y: percentOffset.x/0.50)
} else if(percentOffset.x > 0.50 && percentOffset.x <= 0.75) {
slides[2].mainPic.transform = CGAffineTransform(scaleX: (0.75-percentOffset.x)/0.25, y: (0.75-percentOffset.x)/0.25)
slides[3].mainPic.transform = CGAffineTransform(scaleX: percentOffset.x/0.75, y: percentOffset.x/0.75)
} else if(percentOffset.x > 0.75 && percentOffset.x <= 1) {
slides[3].mainPic.transform = CGAffineTransform(scaleX: (1-percentOffset.x)/0.25, y: (1-percentOffset.x)/0.25)
slides[4].mainPic.transform = CGAffineTransform(scaleX: percentOffset.x, y: percentOffset.x)
}
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "findingClinitionSugue" {
let destination = segue.destination as! FirstAvailableSearchViewController
//destination.consumer = consumer
}
if (timer != nil) {
timer?.invalidate()
}
}
// protocol functions
func initlizeSlides() {
slides = createSlides()
setupSlideScrollView(slides: slides)
}
func initlizeTimer() {
scheduleTimer(5)
}
}
The problem might be about setting the contentSize height value to 0 initally, so even though timer wants scrollView to move, it cannot do that.
Can you try replacing this line:
scrollView.contentSize = CGSize(width: scrollView.contentSize.width, height: 0)
With:
scrollView.contentInsetAdjustmentBehavior = .never
Depending the application and functionality required within the scrollview - could you disable user interaction of the scrollview so it can still be moved programmatically?
That would just be
scrollView.isUserInteractionEnabled = false
This would of course depend on whether you need items in the scrollview to be interactive
Maybe you can subclass your UIScrollView, and override touchesBegan.
class CustomScrollView: UIScrollView {
var touchesDisabled = false
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if touchesDisabled {
// here parse the touches, if they go in the horizontal direction, allow scrolling
// set tolerance for vertical movement
let tolerance: CGFloat = 5.0
let variance = touches.reduce(0, { Yvariation, touch in
Yvariation + abs(touch.location(in: view).y - touch.previousLocation(in: view).y)
})
if variance <= tolerance * CGFloat(touches.count) {
let Xtravelled = touches.reduce(0, { Xstep, touch in
Xstep + (touch.location(in: view).x - touch.previousLocation(in: view).x)
})
// scroll horizontally by the x component of hand gesture
var newFrame: CGRect = scrollView.frame
newFrame.origin.x += Xtravelled
self.scrollRectToVisible(frame, animated: true)
}
}
else {
super.touchesBegan(touches: touches, withEvent: event)
}
}
}
This way you can manually move the scrollview horizontally while disabling vertical movement when touchesDisabled is set true.
If I've understood you problem well, you can stop scrolling whenever you want with this
scrollView.isScrollEnabled = false
Using UIScrollViewDelegate (or KVO on scrollView's contentOffset), you can just counteract any vertical movement in the carousel. Something like this:
var oldYOffset: CGFloat ....
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let deltaY = oldYOffset - scrollView.contentOffset.y
oldYOffset = scrollView.contentOffset.y
scrollView.contentOffset.y -= deltaY
}
This offset change will not be visible to the user. You could even use this to increase the speed of the scrolling, invert the scrolling (pan left and scrollView scrolls right), or entirely lock the motion of the scrollView without touching isScrollEnabled, contentSize, etc.
This turned out to be quite an interesting problem...
While it is easy to lock UIScrollView scrolling to one axis only using the UIScrollViewDelegate, it is impossible to provide smooth scrolling while changing the scrolling programmatically (as you do with the Timer) at the same time.
Below, you will find a DirectionLockingScrollView class I just wrote that should make things easier for you. It's a UIScrollView that you can initialize either programmatically, or via the Interface Builder.
It features isHorizontalScrollingEnabled and isVerticalScrollingEnabled properties.
HOW IT WORKS INTERNALLY
It adds a second "control" UIScrollView that is identical to the main DirectionLockingScrollView and propagates to it all pan events intended for the main scroll view. Every time the "control" scroll view's bounds change, the change is propagated to the main scroll view BUT x and y are altered (based on isHorizontalScrollingEnabled and isVerticalScrollingEnabled) to disable scrolling on the requested axis.
DirectionLockingScrollView.swift
/// `UIScrollView` subclass that supports disabling scrolling on any direction
/// while allowing the other direction to be changed programmatically (via
/// `setContentOffset(_:animated)` or `scrollRectToVisible(_:animated)` or changing the
/// bounds etc.
///
/// Can be initialized programmatically or via the Interface Builder.
class DirectionLockingScrollView: UIScrollView {
var isHorizontalScrollingEnabled = true
var isVerticalScrollingEnabled = true
/// The control scrollview is added below the `DirectionLockingScrollView`
/// and is used to implement all native scrollview behaviours (such as bouncing)
/// based on user input.
///
/// It is required to be able to change the bounds of the `DirectionLockingScrollView`
/// while maintaining scrolling in only one direction and allowing for setting the contentOffset
/// (changing scrolling for any axis - even the disabled ones) programmatically.
private let _controlScrollView = UIScrollView(frame: .zero)
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
installCustomScrollView()
}
override init(frame: CGRect) {
super.init(frame: frame)
installCustomScrollView()
}
override func layoutSubviews() {
super.layoutSubviews()
updateCustomScrollViewFrame()
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
guard let superview = superview else {
_controlScrollView.removeFromSuperview()
return
}
superview.insertSubview(_controlScrollView, belowSubview: self)
updateCustomScrollViewFrame()
}
// MARK: - UIEvent propagation
func viewIgnoresEvents(_ view: UIView?) -> Bool {
let viewIgnoresEvents =
view == nil ||
view == self ||
!view!.isUserInteractionEnabled ||
!(view is UIControl && (view!.gestureRecognizers ?? []).count == 0)
return viewIgnoresEvents
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let view = super.hitTest(point, with: event)
if viewIgnoresEvents(view) {
return _controlScrollView
}
return view
}
// MARK: - Main scrollview settings propagation to `controlScrollView`
override var contentInset: UIEdgeInsets {
didSet {
_controlScrollView.contentInset = contentInset
}
}
override var contentScaleFactor: CGFloat {
didSet {
_controlScrollView.contentScaleFactor = contentScaleFactor
}
}
override var contentSize: CGSize {
didSet {
_controlScrollView.contentSize = contentSize
}
}
override var bounces: Bool {
didSet {
_controlScrollView.bounces = bounces
}
}
override var bouncesZoom: Bool {
didSet {
_controlScrollView.bouncesZoom = bouncesZoom
}
}
}
extension DirectionLockingScrollView: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
updateBoundsFromCustomScrollView(scrollView)
}
}
private extension DirectionLockingScrollView {
/// Propagates `controlScrollView` bounds to the actual scrollview.
/// - Parameter scrollView: If the scrollview provided is not the `controlScrollView`
// the main scrollview bounds are not updated.
func updateBoundsFromCustomScrollView(_ scrollView: UIScrollView) {
if scrollView != _controlScrollView {
return
}
var newBounds = scrollView.bounds.origin
if !isHorizontalScrollingEnabled {
newBounds.x = self.contentOffset.x
}
if !isVerticalScrollingEnabled {
newBounds.y = self.contentOffset.y
}
bounds.origin = newBounds
}
func installCustomScrollView() {
_controlScrollView.delegate = self
_controlScrollView.contentSize = contentSize
_controlScrollView.showsVerticalScrollIndicator = false
_controlScrollView.showsHorizontalScrollIndicator = false
// The panGestureRecognizer is removed because pan gestures might be triggered
// on subviews of the scrollview which do not ignore touch events (determined
// by `viewIgnoresEvents(_ view: UIView?)`). This can happen for example
// if you tap and drag on a button inside the scroll view.
removeGestureRecognizer(panGestureRecognizer)
}
func updateCustomScrollViewFrame() {
if _controlScrollView.frame == frame { return }
_controlScrollView.frame = frame
}
}
USAGE
After you've included the above class in your app, don't forget to change your scroll view's class to DirectionLockingScrollView in your .xib or .storyboard.
Then update your code as below (only two lines changed, marked with // *****).
class CaruselleScreenViewController: UIViewController, CaruselleScreenViewProtocol, UIScrollViewDelegate {
var myPresenter: CaruselleScreenPresenterProtocol?
#IBOutlet weak var pageControl: UIPageControl!
#IBOutlet weak var scrollView: DirectionLockingScrollView! // *****
var slides:[CaruselleTipsCard] = [];
var timer:Timer?
var currentPageMultiplayer = 0
override func viewDidLoad() {
super.viewDidLoad()
myPresenter = CaruselleScreenPresenter(controller: self)
//initlizes view
pageControl.numberOfPages = slides.count
pageControl.currentPage = 0
view.bringSubview(toFront: pageControl)
scrollView.isHorizontalScrollingEnabled = false // *****
//delegates
scrollView.delegate = self
////blocks vertical movement
scrollView.showsVerticalScrollIndicator = false
//scrollView.contentSize = CGSize(width: scrollView.contentSize.width, height: 0) //disable vertical
}
func scheduleTimer(_ timeInterval: TimeInterval){
timer = Timer.scheduledTimer(timeInterval: timeInterval, target: self, selector: #selector(timerCall), userInfo: nil, repeats: false)
}
#objc func timerCall(){
print("Timer executed")
currentPageMultiplayer = currentPageMultiplayer + 1
if (currentPageMultiplayer == 5) {
currentPageMultiplayer = 0
}
pageControl.currentPage = currentPageMultiplayer
scrollToPage(pageToMove: currentPageMultiplayer)
scheduleTimer(5)
}
func scrollToPage(pageToMove: Int) {
print ("new one")
var frame: CGRect = scrollView.frame
frame.origin.x = frame.size.width * CGFloat(pageToMove)
frame.origin.y = -35
scrollView.scrollRectToVisible(frame, animated: true)
}
func createSlides() -> [CaruselleTipsCard] {
let slide1:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
slide1.mainPic.image = UIImage(named: "backlightingIllo")
//
let slide2:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
slide2.mainPic.image = UIImage(named: "comfortableIllo")
//
let slide3:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
slide3.mainPic.image = UIImage(named: "pharmacyIllo")
//
let slide4:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
slide4.mainPic.image = UIImage(named: "batteryIllo")
//
let slide5:CaruselleTipsCard = Bundle.main.loadNibNamed("CaruselleTipsCard", owner: self, options: nil)?.first as! CaruselleTipsCard
slide5.mainPic.image = UIImage(named: "wiFiIllo")
return [slide1, slide2, slide3, slide4, slide5]
}
func setupSlideScrollView(slides : [CaruselleTipsCard]) {
scrollView.frame = CGRect(x: 0, y: 0, width: view.frame.width, height: view.frame.height)
scrollView.contentSize = CGSize(width: view.frame.width * CGFloat(slides.count), height: view.frame.height)
scrollView.isPagingEnabled = true
for i in 0 ..< slides.count {
slides[i].frame = CGRect(x: view.frame.width * CGFloat(i), y: 0, width: view.frame.width, height: view.frame.height)
scrollView.addSubview(slides[i])
}
}
//////
/*
* default function called when view is scrolled. In order to enable callback
* when scrollview is scrolled, the below code needs to be called:
* slideScrollView.delegate = self or
*/
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let pageIndex = round(scrollView.contentOffset.x/view.frame.width)
pageControl.currentPage = Int(pageIndex)
let maximumHorizontalOffset: CGFloat = scrollView.contentSize.width - scrollView.frame.width
let currentHorizontalOffset: CGFloat = scrollView.contentOffset.x
// vertical
let maximumVerticalOffset: CGFloat = scrollView.contentSize.height - scrollView.frame.height
let currentVerticalOffset: CGFloat = scrollView.contentOffset.y
let percentageHorizontalOffset: CGFloat = currentHorizontalOffset / maximumHorizontalOffset
let percentageVerticalOffset: CGFloat = currentVerticalOffset / maximumVerticalOffset
/*
* below code changes the background color of view on paging the scrollview
*/
// self.scrollView(scrollView, didScrollToPercentageOffset: percentageHorizontalOffset)
/*
* below code scales the imageview on paging the scrollview
*/
let percentOffset: CGPoint = CGPoint(x: percentageHorizontalOffset, y: percentageVerticalOffset)
if(percentOffset.x > 0 && percentOffset.x <= 0.25) {
slides[0].mainPic.transform = CGAffineTransform(scaleX: (0.25-percentOffset.x)/0.25, y: (0.25-percentOffset.x)/0.25)
slides[1].mainPic.transform = CGAffineTransform(scaleX: percentOffset.x/0.25, y: percentOffset.x/0.25)
} else if(percentOffset.x > 0.25 && percentOffset.x <= 0.50) {
slides[1].mainPic.transform = CGAffineTransform(scaleX: (0.50-percentOffset.x)/0.25, y: (0.50-percentOffset.x)/0.25)
slides[2].mainPic.transform = CGAffineTransform(scaleX: percentOffset.x/0.50, y: percentOffset.x/0.50)
} else if(percentOffset.x > 0.50 && percentOffset.x <= 0.75) {
slides[2].mainPic.transform = CGAffineTransform(scaleX: (0.75-percentOffset.x)/0.25, y: (0.75-percentOffset.x)/0.25)
slides[3].mainPic.transform = CGAffineTransform(scaleX: percentOffset.x/0.75, y: percentOffset.x/0.75)
} else if(percentOffset.x > 0.75 && percentOffset.x <= 1) {
slides[3].mainPic.transform = CGAffineTransform(scaleX: (1-percentOffset.x)/0.25, y: (1-percentOffset.x)/0.25)
slides[4].mainPic.transform = CGAffineTransform(scaleX: percentOffset.x, y: percentOffset.x)
}
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "findingClinitionSugue" {
let destination = segue.destination as! FirstAvailableSearchViewController
//destination.consumer = consumer
}
if (timer != nil) {
timer?.invalidate()
}
}
// protocol functions
func initlizeSlides() {
slides = createSlides()
setupSlideScrollView(slides: slides)
}
func initlizeTimer() {
scheduleTimer(5)
}
}

Swift interactive transition - view from second view controller is flashing when cancelling the transition

i´m trying to make my own interactive transition in swift. In my sample project I have two view controllers. With my own transition I can swipe to each other. My problem is: when swiping from the first to the second view controller the second (where I want to swipe in) view controller is appears for a millisecond on the screen, when I did cancel the transition in my pan gesture.
I tried to solve the problem by taking a snapshot from the first view controller and lay it on the transition layer when the transition cancelled. That worked on my iPhone 8, but on the iPhone XS I get the same flash for a millisecond.
The flashing only appears when I swipe very fast over the screen.
Anyone with the same problem.
PangestureHandler:
#IBAction func HandlePan(_ sender: UIPanGestureRecognizer) {
let percentThreshold:CGFloat = 0.25
let translation = sender.translation(in: view)
var verticalMovement = (translation.y) / view.bounds.height
verticalMovement = verticalMovement - verticalMovement - verticalMovement
let downwardMovement = fmaxf(Float(verticalMovement), 0.0)
let downwardMovementPercent = fminf(downwardMovement, 1.0)
let progress = CGFloat(downwardMovementPercent)
let movementToStart: CGFloat = 0.0
let interactor = self.interactor
switch sender.state {
case .began:
print(".began")
case .changed:
print("progress: \(progress)")
if progress > movementToStart && !interactor.hasStarted {
interactor.hasStarted = true
performSegue(withIdentifier: "segueToSecondVC", sender: nil)
interactor.update(progress - movementToStart)
}
if interactor.hasStarted {
interactor.update(progress - movementToStart)
interactor.shouldFinish = progress > percentThreshold
}
case .cancelled:
print("cancel")
interactor.hasStarted = false
interactor.cancel()
case .ended:
interactor.hasStarted = false
if interactor.shouldFinish {
interactor.finish()
} else {
interactor.cancel()
print("interactor cancel")
}
default:
break
}
}
AnimatorClass:
import UIKit
class PresentAnimator: NSObject, UIViewControllerAnimatedTransitioning {
private var myView: UIView!
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.6
}
func animationEnded(_ transitionCompleted: Bool) {
if !transitionCompleted {
self.myView.alpha = 1.0
}
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let fromVC = transitionContext.viewController(forKey: .from), let toVC = transitionContext.viewController(forKey: .to) else { return }
let containerView = transitionContext.containerView
let screenBounds = UIScreen.main.bounds
let startPoint = CGPoint(x: 0, y: UIScreen.main.bounds.height )
toVC.view.frame = CGRect(origin: startPoint, size: screenBounds.size)
toVC.view.clipsToBounds = true
self.myView = fromVC.view.snapshotView(afterScreenUpdates: false)
containerView.addSubview(myView)
self.myView.alpha = 0.0
containerView.insertSubview(toVC.view, aboveSubview: fromVC.view)
let bottomLeftCorner = CGPoint(x: 0, y: 0)
let finalFrame = CGRect(origin: bottomLeftCorner, size: screenBounds.size)
let duration = transitionDuration(using: transitionContext)
UIView.animate(
withDuration: duration, delay: 10.0, options: .curveLinear,
animations: {
toVC.view.frame = finalFrame
self.myView.removeFromSuperview()
},
completion: { _ in
self.myView.removeFromSuperview()
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
}

Black edges on return transition

I have implemented the following animated transition:
class PopInAndOutAnimator: NSObject, UIViewControllerAnimatedTransitioning {
fileprivate let _operationType : UINavigationControllerOperation
fileprivate let _transitionDuration : TimeInterval
init(operation: UINavigationControllerOperation) {
_operationType = operation
_transitionDuration = 0.4
}
init(operation: UINavigationControllerOperation, andDuration duration: TimeInterval) {
_operationType = operation
_transitionDuration = duration
}
//MARK: Push and Pop animations performers
internal func performPushTransition(_ transitionContext: UIViewControllerContextTransitioning) {
guard let toView = transitionContext.view(forKey: UITransitionContextViewKey.to) else {
// Something really bad happend and it is not possible to perform the transition
print("ERROR: Transition impossible to perform since either the destination view or the conteiner view are missing!")
return
}
let container = transitionContext.containerView
guard let fromViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from) as? CollectionPushAndPoppable,
let fromView = fromViewController.collectionView,
let currentCell = fromViewController.sourceCell else {
// There are not enough info to perform the animation but it is still possible
// to perform the transition presenting the destination view
container.addSubview(toView)
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
return
}
// Add to container the destination view
container.addSubview(toView)
// Prepare the screenshot of the destination view for animation
let screenshotToView = UIImageView(image: toView.screenshot)
// set the frame of the screenshot equals to the cell's one
screenshotToView.frame = currentCell.frame
// Now I get the coordinates of screenshotToView inside the container
let containerCoord = fromView.convert(screenshotToView.frame.origin, to: container)
// set a new origin for the screenshotToView to overlap it to the cell
screenshotToView.frame.origin = containerCoord
// Prepare the screenshot of the source view for animation
let screenshotFromView = UIImageView(image: currentCell.screenshot)
screenshotFromView.frame = screenshotToView.frame
// Add screenshots to transition container to set-up the animation
container.addSubview(screenshotToView)
container.addSubview(screenshotFromView)
// Set views initial states
toView.isHidden = true
screenshotToView.isHidden = true
// Delay to guarantee smooth effects
let delayTime = DispatchTime.now() + Double(Int64(0.08 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC)
DispatchQueue.main.asyncAfter(deadline: delayTime) {
screenshotToView.isHidden = false
}
UIView.animate(withDuration: _transitionDuration, delay: 0, usingSpringWithDamping: 0, initialSpringVelocity: 0, options: [], animations: { () -> Void in
screenshotFromView.alpha = 0.0
screenshotToView.frame = UIScreen.main.bounds
screenshotToView.frame.origin = CGPoint(x: 0.0, y: 0.0)
screenshotFromView.frame = screenshotToView.frame
}) { _ in
screenshotToView.removeFromSuperview()
screenshotFromView.removeFromSuperview()
toView.isHidden = false
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
}
internal func performPopTransition(_ transitionContext: UIViewControllerContextTransitioning) {
guard let toView = transitionContext.view(forKey: UITransitionContextViewKey.to) else {
// Something really bad happend and it is not possible to perform the transition
print("ERROR: Transition impossible to perform since either the destination view or the conteiner view are missing!")
return
}
let container = transitionContext.containerView
guard let toViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) as? CollectionPushAndPoppable,
let toCollectionView = toViewController.collectionView,
let fromViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from),
let fromView = fromViewController.view,
let currentCell = toViewController.sourceCell else {
// There are not enough info to perform the animation but it is still possible
// to perform the transition presenting the destination view
container.addSubview(toView)
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
return
}
// Add destination view to the container view
container.addSubview(toView)
// Prepare the screenshot of the source view for animation
let screenshotFromView = UIImageView(image: fromView.screenshot)
screenshotFromView.frame = fromView.frame
// Prepare the screenshot of the destination view for animation
let screenshotToView = UIImageView(image: currentCell.screenshot)
screenshotToView.frame = screenshotFromView.frame
// Add screenshots to transition container to set-up the animation
container.addSubview(screenshotToView)
container.insertSubview(screenshotFromView, belowSubview: screenshotToView)
// Set views initial states
screenshotToView.alpha = 0.0
fromView.isHidden = true
currentCell.isHidden = true
let containerCoord = toCollectionView.convert(currentCell.frame.origin, to: container)
UIView.animate(withDuration: _transitionDuration, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0, options: [], animations: { () -> Void in
screenshotToView.alpha = 1.0
screenshotFromView.frame = currentCell.frame
screenshotFromView.frame.origin = containerCoord
screenshotToView.frame = screenshotFromView.frame
}) { _ in
currentCell.isHidden = false
screenshotFromView.removeFromSuperview()
screenshotToView.removeFromSuperview()
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
}
//MARK: UIViewControllerAnimatedTransitioning
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return _transitionDuration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
if _operationType == .push {
performPushTransition(transitionContext)
} else if _operationType == .pop {
performPopTransition(transitionContext)
}
}
}
The issue that I'm having is when my collection view cell returns. There is a small delay where the bottom of the cell has a black line. I'm not sure how to remove that delay or make that line white so that it isn't noticeable.
A quick hack is to set the background view color to white.