iosswiftuiviewanimation

How to fix animation blending?


I have a code that scrolls through full-screen images forward and backward when tapping the left or right side of the screen. When an image appears on the screen, it performs one of the animation types: top, bottom, left, right, zoomin, zoomout — each of these animations consists of two sub-animations (the first animation is fast, and the second one is looped).

The problem is that sometimes, when switching images, the animation coordinates stack. Under normal circumstances, animations should only affect either X, Y, or Scale. But in my case, it sometimes happens that images move diagonally (X + Y), the animation goes beyond the image boundaries, and I see black areas on the screen. This shouldn't happen. How can I fix this?

I'm using removeAllAnimations before each animation, but it doesn't help.

full code:

class ReaderController: UIViewController, CAAnimationDelegate {
    
    var pagesData = [PageData]()
    var index = Int()
    var pageIndex: Int = -1
    
    let pageContainer: UIView = {
        let view = UIView()
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    let pageViews: [PageLayout] = {
        let view = [PageLayout(), PageLayout()]
        view[0].translatesAutoresizingMaskIntoConstraints = false
        view[1].translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    private weak var currentTransitionView: PageLayout?
        
    override func viewDidLoad() {
        super.viewDidLoad()

        setupViews()
        setupConstraints()

        pageViews[0].index = index
        pageViews[1].index = index
        pageViews[0].pageIndex = pageIndex
        pageViews[1].pageIndex = pageIndex
        
        pageTransition(animated: false, direction: "fromRight")
    }
        
    func setupViews() {
        pageContainer.addSubview(pageViews[0])
        pageContainer.addSubview(pageViews[1])
        view.addSubview(pageContainer)
    }
        
    func setupConstraints() {
        pageContainer.topAnchor.constraint(equalTo: view.topAnchor, constant: 0.0).isActive = true
        pageContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0.0).isActive = true
        pageContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0.0).isActive = true
        pageContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0.0).isActive = true

        pageViews[0].topAnchor.constraint(equalTo: pageContainer.topAnchor).isActive = true
        pageViews[0].bottomAnchor.constraint(equalTo: pageContainer.bottomAnchor).isActive = true
        pageViews[0].leadingAnchor.constraint(equalTo: pageContainer.leadingAnchor).isActive = true
        pageViews[0].trailingAnchor.constraint(equalTo: pageContainer.trailingAnchor).isActive = true

        pageViews[1].topAnchor.constraint(equalTo: pageContainer.topAnchor).isActive = true
        pageViews[1].bottomAnchor.constraint(equalTo: pageContainer.bottomAnchor).isActive = true
        pageViews[1].leadingAnchor.constraint(equalTo: pageContainer.leadingAnchor).isActive = true
        pageViews[1].trailingAnchor.constraint(equalTo: pageContainer.trailingAnchor).isActive = true
    }
        
    func loadData(fileName: Any) -> PagesData {
        var url = NSURL()
        url = Bundle.main.url(forResource: "text", withExtension: "json")! as NSURL
        let data = try! Data(contentsOf: url as URL)
        let person = try! JSONDecoder().decode(PagesData.self, from: data)
        return person
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        for touch in touches {
            let location = touch.location(in: view.self)

            if view.safeAreaInsets.left > 30 {
                if (location.x > self.view.frame.size.width - (view.safeAreaInsets.left * 1.5)) {
                    pageTransition(animated: true, direction: "fromRight")
                } else if (location.x < (view.safeAreaInsets.left * 1.5)) {
                    pageTransition(animated: true, direction: "fromLeft")
                }
            }

            else {
                if (location.x > self.view.frame.size.width - 40) {
                    pageTransition(animated: true, direction: "fromRight")
                } else if (location.x < 40) {
                    pageTransition(animated: true, direction: "fromLeft")
                }
            }
        }
        
    }
    
    func pageTransition(animated: Bool, direction: String) {
        let result = loadData(fileName: pagesData)
        
        switch direction {
        case "fromRight":
            pageIndex += 1
        case "fromLeft":
            pageIndex -= 1
        default: break
        }
        
        pageViews[0].pageIndex = pageIndex
        pageViews[1].pageIndex = pageIndex
        
        guard pageIndex >= 0 && pageIndex < result.pagesData.count else {
            pageIndex = max(0, min(pageIndex, result.pagesData.count - 1))
            return
        }
        
        let fromView = pageViews[0].isHidden ? pageViews[1] : pageViews[0]
        let toView = pageViews[0].isHidden ? pageViews[0] : pageViews[1]
        toView.imageView.layer.removeAllAnimations()
        toView.imageView.transform = .identity
        toView.configure(theData: result.pagesData[pageIndex])
        if animated {
            fromView.isHidden = true
            toView.isHidden = false
        } else {
            fromView.isHidden = true
            toView.isHidden = false
        }
    }
    
}

class PageLayout: UIView {
            
    var index = Int()
    var pageIndex = Int()
    
    let imageView: UIImageView = {
        let image = UIImageView()
        image.contentMode = .scaleAspectFill
        image.translatesAutoresizingMaskIntoConstraints = false
        return image
    }()

    var imageViewTopConstraint = NSLayoutConstraint()
    var imageViewBottomConstraint = NSLayoutConstraint()
    var imageViewLeadingConstraint = NSLayoutConstraint()
    var imageViewTrailingConstraint = NSLayoutConstraint()
    
    var imagePosition = ""
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        addSubview(imageView)
        setupConstraints()
    }
    
    func setupConstraints() {
        
        imageView.layer.removeAllAnimations()
        imageView.transform = .identity
        
        removeConstraints([imageViewTopConstraint, imageViewBottomConstraint, imageViewLeadingConstraint,
                           imageViewTrailingConstraint])
        
        switch imagePosition {
            
        case "top":
            
            imageView.transform = .identity
            
            imageViewTopConstraint = imageView.topAnchor.constraint(equalTo: topAnchor, constant: -40.0)
            imageViewBottomConstraint = imageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0.0)
            imageViewLeadingConstraint = imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0)
            imageViewTrailingConstraint = imageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0)
            
            addConstraints([imageViewTopConstraint, imageViewBottomConstraint, imageViewLeadingConstraint, imageViewTrailingConstraint])
            
            UIView.animate(withDuration: 2.0, delay: 0, options: [.curveEaseOut, .allowUserInteraction, .beginFromCurrentState], animations: {
                self.imageView.transform = CGAffineTransform(translationX: 0, y: 40.0)
            }, completion: { _ in
                UIView.animate(withDuration: 6.0, delay: 0, options: [.curveLinear, .autoreverse, .repeat, .beginFromCurrentState, .allowUserInteraction], animations: {
                    self.imageView.transform = self.imageView.transform.translatedBy(x: 0, y: -40.0)
                }, completion: nil)
            })
            
        case "bottom":
            
            imageView.transform = .identity
            
            imageViewTopConstraint = imageView.topAnchor.constraint(equalTo: topAnchor, constant: 0.0)
            imageViewBottomConstraint = imageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 40.0)
            imageViewLeadingConstraint = imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0)
            imageViewTrailingConstraint = imageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0)
            
            addConstraints([imageViewTopConstraint, imageViewBottomConstraint, imageViewLeadingConstraint, imageViewTrailingConstraint])
            
            UIView.animate(withDuration: 2.0, delay: 0, options: [.curveEaseOut, .allowUserInteraction, .beginFromCurrentState], animations: {
                self.imageView.transform = CGAffineTransform(translationX: 0, y: -40)
            }, completion: { _ in
                UIView.animate(withDuration: 6.0, delay: 0, options: [.curveLinear, .autoreverse, .repeat, .beginFromCurrentState, .allowUserInteraction], animations: {
                    self.imageView.transform = self.imageView.transform.translatedBy(x: 0, y: 40)
                }, completion: nil)
            })
            
        case "left":
            
            imageView.transform = .identity
            
            imageViewTopConstraint = imageView.topAnchor.constraint(equalTo: topAnchor, constant: 0.0)
            imageViewBottomConstraint = imageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0.0)
            imageViewLeadingConstraint = imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -40.0)
            imageViewTrailingConstraint = imageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0)
            
            addConstraints([imageViewTopConstraint, imageViewBottomConstraint, imageViewLeadingConstraint, imageViewTrailingConstraint])
            
            UIView.animate(withDuration: 2.0, delay: 0, options: [.curveEaseOut, .allowUserInteraction, .beginFromCurrentState], animations: {
                self.imageView.transform = CGAffineTransform(translationX: 40, y: 0)
            }, completion: { _ in
                UIView.animate(withDuration: 6.0, delay: 0, options: [.curveLinear, .autoreverse, .repeat, .beginFromCurrentState, .allowUserInteraction], animations: {
                    self.imageView.transform = self.imageView.transform.translatedBy(x: -40, y: 0)
                }, completion: nil)
            })
            
        case "right":
            
            imageView.transform = .identity
            
            imageViewTopConstraint = imageView.topAnchor.constraint(equalTo: topAnchor, constant: 0.0)
            imageViewBottomConstraint = imageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0.0)
            imageViewLeadingConstraint = imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0)
            imageViewTrailingConstraint = imageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 40.0)
            
            addConstraints([imageViewTopConstraint, imageViewBottomConstraint, imageViewLeadingConstraint, imageViewTrailingConstraint])
            
            UIView.animate(withDuration: 2.0, delay: 0, options: [.curveEaseOut, .allowUserInteraction, .beginFromCurrentState], animations: {
                self.imageView.transform = CGAffineTransform(translationX: -40, y: 0)
            }, completion: { _ in
                UIView.animate(withDuration: 6.0, delay: 0, options: [.curveLinear, .autoreverse, .repeat, .beginFromCurrentState, .allowUserInteraction], animations: {
                    self.imageView.transform = self.imageView.transform.translatedBy(x: 40, y: 0)
                }, completion: nil)
            })
            
        case "zoomin":
            
            imageView.transform = CGAffineTransformScale(.identity, 1.0, 1.0)
            
            imageViewTopConstraint = imageView.topAnchor.constraint(equalTo: topAnchor, constant: 0.0)
            imageViewBottomConstraint = imageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0.0)
            imageViewLeadingConstraint = imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0)
            imageViewTrailingConstraint = imageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0)
            
            addConstraints([imageViewTopConstraint, imageViewBottomConstraint, imageViewLeadingConstraint, imageViewTrailingConstraint])
            
            UIView.animate(withDuration: 2.0, delay: 0, options: [.curveEaseOut, .allowUserInteraction, .beginFromCurrentState], animations: {
                self.imageView.transform = CGAffineTransform(scaleX: 1.1, y: 1.1)
            }, completion: { _ in
                UIView.animate(withDuration: 6.0, delay: 0, options: [.curveLinear, .autoreverse, .repeat, .beginFromCurrentState, .allowUserInteraction], animations: {
                    self.imageView.transform = .identity
                }, completion: nil)
            })
            
        case "zoomout":
            
            imageView.transform = CGAffineTransformScale(.identity, 1.1, 1.1)
            
            imageViewTopConstraint = imageView.topAnchor.constraint(equalTo: topAnchor, constant: 0.0)
            imageViewBottomConstraint = imageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0.0)
            imageViewLeadingConstraint = imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0)
            imageViewTrailingConstraint = imageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0)
            
            addConstraints([imageViewTopConstraint, imageViewBottomConstraint, imageViewLeadingConstraint, imageViewTrailingConstraint])
            
            UIView.animate(withDuration: 2.0, delay: 0, options: [.curveEaseOut, .allowUserInteraction, .beginFromCurrentState], animations: {
                self.imageView.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
            }, completion: { _ in
                UIView.animate(withDuration: 6.0, delay: 0, options: [.curveLinear, .autoreverse, .repeat, .beginFromCurrentState, .allowUserInteraction], animations: {
                    self.imageView.transform = self.imageView.transform.scaledBy(x: 1.1, y: 1.1)
                }, completion: nil)
            })
            
        default:
            
            imageView.transform = .identity
            
            imageViewTopConstraint = imageView.topAnchor.constraint(equalTo: topAnchor, constant: 0.0)
            imageViewBottomConstraint = imageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0.0)
            imageViewLeadingConstraint = imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0)
            imageViewTrailingConstraint = imageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0)
            
            addConstraints([imageViewTopConstraint, imageViewBottomConstraint, imageViewLeadingConstraint, imageViewTrailingConstraint])
        }
    }
        
    func configure(theData: PageData) {
        imageView.image = UIImage(named: "page\(pageIndex+1)")
        imagePosition = theData.imagePosition
        setupConstraints()
    }
    
    required init?(coder: NSCoder) {
        fatalError("Not happening")
    }
    
}

struct PagesData: Decodable {
    var pagesData: [PageData]
}

struct PageData: Decodable {
    let textData, textPosition, textColor, shadowColor, textAlignment, imagePosition: String
}

JSON:

{
    "pagesData" : [
        
        {
            "textData" : "",
            "textPosition" : "topLeft",
            "textColor" : "FFFFFF",
            "shadowColor" : "000000",
            "textAlignment" : "left",
            "imagePosition" : "left",
        },
        
        {
            "textData" : "",
            "textPosition" : "bottomLeft",
            "textColor" : "FFFFFF",
            "shadowColor" : "000000",
            "textAlignment" : "left",
            "imagePosition" : "bottom",
        },
        
        {
            "textData" : "",
            "textPosition" : "zoomin",
            "textColor" : "FFFFFF",
            "shadowColor" : "000000",
            "textAlignment" : "left",
            "imagePosition" : "right",
        },
        
        {
            "textData" : "",
            "textPosition" : "bottomCenter",
            "textColor" : "FFFFFF",
            "shadowColor" : "000000",
            "textAlignment" : "left",
            "imagePosition" : "zoomout",
        },
        
        {
            "textData" : "",
            "textPosition" : "topLeft",
            "textColor" : "FFFFFF",
            "shadowColor" : "000000",
            "textAlignment" : "left",
            "imagePosition" : "left",
        },
        
    ]
}

Solution

  • I can't reproduce your "images move diagonally (X + Y)" ... although when tapping quickly I do see "the animation goes beyond the image boundaries".

    What might correct the issue is to NOT execute the second part of the animation if the first part has not completed.

    Try changing your animation blocks to this:

        UIView.animate(withDuration: 2.0, delay: 0, options: [.curveEaseOut, .allowUserInteraction, .beginFromCurrentState], animations: {
            self.imageView.transform = CGAffineTransform(translationX: 0, y: 40.0)
        }, completion: { bCompleted in
            if bCompleted {
                UIView.animate(withDuration: 6.0, delay: 0, options: [.curveLinear, .autoreverse, .repeat, .beginFromCurrentState, .allowUserInteraction], animations: {
                    self.imageView.transform = self.imageView.transform.translatedBy(x: 0, y: -40.0)
                }, completion: nil)
            }
        })