Swift: UIView.animate works unexpectedly

2019-05-31 00:45发布

问题:

I'm trying to make a "record button" which is a UIView with a gesture recognizer for my app. Right now I'm implementing the feature that when I click the view, I want to scale down the inner circle (the red one) however it ends up with an unexpected transformation. I use UIView.animate function to do this, and below is my relating code:

import UIKit

@IBDesignable
class RecordView: UIView {

    @IBInspectable
    var borderColor: UIColor = UIColor.clear {
        didSet {
            self.layer.borderColor = borderColor.cgColor
        }
    }

    @IBInspectable
    var borderWidth: CGFloat = 20 {
        didSet {
            layer.borderWidth = borderWidth
        }
    }

    @IBInspectable
    var cornerRadius: CGFloat = 100 {
        didSet {
            layer.cornerRadius = cornerRadius
        }
    }

    private var fillView = UIView()

    private func setupFillView() {
        let radius = (self.cornerRadius - self.borderWidth) * 0.95
        fillView.frame = CGRect(origin: CGPoint.zero, size: CGSize(width: radius * 2, height: radius * 2))
        fillView.center = CGPoint(x: self.bounds.midX, y: self.bounds.midY)
        fillView.layer.cornerRadius = radius
        fillView.backgroundColor = UIColor.red
        self.addSubview(fillView)
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        setupFillView()
    }

    func didClick() {
        UIView.animate(withDuration: 1.0, animations: {
            self.fillView.transform = CGAffineTransform(scaleX: 0.6, y: 0.6)
        }) { (true) in
            print()
        }
    }

}

In my ViewController, I have:

@IBAction func recoreViewTapped(_ sender: UITapGestureRecognizer) {
        recordView.didClick()
    }

However it ends up with this effect: https://media.giphy.com/media/3ohjUQrWt7vfIxzBrG/giphy.gif. I'm a beginner and really don't know what's wrong with my code?

回答1:

The issue is that you're applying a transform, which is triggering the layoutSubviews again, so the resetting of the frame of fillView is being applied on the transformed view.

There are at least two options:

  1. You could just transform the whole RecordButton view:

    @IBDesignable
    class RecordView: UIView {
    
        @IBInspectable
        var borderColor: UIColor = .clear { didSet { layer.borderColor = borderColor.cgColor } }
    
        @IBInspectable
        var borderWidth: CGFloat = 20 { didSet { layer.borderWidth = borderWidth; setNeedsLayout() } }
    
        private var fillView = UIView()
    
        override init(frame: CGRect) {
            super.init(frame: frame)
    
            configure()
        }
    
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
    
            configure()
        }
    
        private func configure() {
            fillView.backgroundColor = .red
            self.addSubview(fillView)
        }
    
        override func layoutSubviews() {
            super.layoutSubviews()
    
            let radius = min(bounds.width, bounds.height) / 2  - borderWidth
            fillView.frame = CGRect(origin: CGPoint(x: bounds.midX - radius, y: bounds.midY - radius),
                                    size: CGSize(width: radius * 2, height: radius * 2))
            fillView.layer.cornerRadius = radius
        }
    
        func didClick() {
            UIView.animate(withDuration: 1.0, animations: {
                self.transform = CGAffineTransform(scaleX: 0.6, y: 0.6)
            }, completion: { _ in
                print("completion", #function)
            })
        }
    
    }
    
  2. Or you could put the fillView inside a container, and then the transform of the fillView won't trigger the layoutSubviews of the RecordView:

    @IBDesignable
    class RecordView: UIView {
    
        @IBInspectable
        var borderColor: UIColor = .clear { didSet { layer.borderColor = borderColor.cgColor } }
    
        @IBInspectable
        var borderWidth: CGFloat = 20 { didSet { layer.borderWidth = borderWidth; setNeedsLayout() } }
    
        private var fillView = UIView()
        private var containerView = UIView()
    
        override init(frame: CGRect) {
            super.init(frame: frame)
    
            configure()
        }
    
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
    
            configure()
        }
    
        private func configure() {
            fillView.backgroundColor = .red
            self.addSubview(containerView)
            containerView.addSubview(fillView)
        }
    
        override func layoutSubviews() {
            super.layoutSubviews()
    
            containerView.frame = bounds
    
            let radius = min(bounds.width, bounds.height) / 2  - borderWidth
            fillView.frame = CGRect(origin: CGPoint(x: bounds.midX - radius, y: bounds.midY - radius),
                                    size: CGSize(width: radius * 2, height: radius * 2))
            fillView.layer.cornerRadius = radius
        }
    
        func didClick() {
            UIView.animate(withDuration: 1.0, animations: {
                self.fillView.transform = CGAffineTransform(scaleX: 0.6, y: 0.6)
            }, completion: { _ in
                print("completion", #function)
            })
        }
    
    }
    

By the way, a few other little things:

  • As you and I discussed elsewhere, the initial configuration (e.g. adding of the subview) should be called from init. But any frame/bounds contingent stuff should be called from layoutSubviews.

  • Completely unrelated, but the parameter passed to completion of UIView.animate(withDuration:animations:completion:) is a Boolean indicating whether the animation is finished or not. If you're not going to use that Boolean, you should use _, not (true).

  • Longer term, I'd suggest moving the "touches"/gesture related stuff into the RecordButton. I can easily imagine you getting fancier: e.g. one animation as you "touch down" (shrink the button to render the "depress" vibe) and another for "lift up" (e.g. unshrink the button and convert the round "record" button to a square "stop" button). You can then have the button inform the view controller when something significant happens (e.g. record or stop recognized), but reduces complexity from the view controller itself.

    protocol RecordViewDelegate: class {
        func didTapRecord()
        func didTapStop()
    }
    
    @IBDesignable
    class RecordView: UIView {
    
        @IBInspectable
        var borderColor: UIColor = .clear { didSet { layer.borderColor = borderColor.cgColor } }
    
        @IBInspectable
        var borderWidth: CGFloat = 20 { didSet { layer.borderWidth = borderWidth; setNeedsLayout() } }
    
        weak var delegate: RecordViewDelegate?
    
        var isRecordButton = true
    
        private var fillView = UIView()
        private var containerView = UIView()
    
        override init(frame: CGRect) {
            super.init(frame: frame)
    
            configure()
        }
    
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
    
            configure()
        }
    
        private func configure() {
            fillView.backgroundColor = .red
            self.addSubview(containerView)
            containerView.addSubview(fillView)
        }
    
        private var radius: CGFloat { return min(bounds.width, bounds.height) / 2  - borderWidth }
    
        override func layoutSubviews() {
            super.layoutSubviews()
    
            containerView.frame = bounds
    
            fillView.frame = CGRect(origin: CGPoint(x: bounds.midX - radius, y: bounds.midY - radius),
                                    size: CGSize(width: radius * 2, height: radius * 2))
            fillView.layer.cornerRadius = isRecordButton ? radius : 0
        }
    
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            UIView.animate(withDuration: 0.25) {
                self.fillView.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
                self.fillView.backgroundColor = UIColor(red: 0.75, green: 0, blue: 0, alpha: 1)
            }
        }
    
        override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
            if let touch = touches.first, containerView.bounds.contains(touch.location(in: containerView)) {
                if isRecordButton {
                    delegate?.didTapRecord()
                } else {
                    delegate?.didTapStop()
                }
                isRecordButton = !isRecordButton
            }
    
            UIView.animate(withDuration: 0.25) {
                self.fillView.transform = .identity
                self.fillView.backgroundColor = UIColor.red
                self.fillView.layer.cornerRadius = self.isRecordButton ? self.radius : 0
            }
    
        }
    }
    

    That yields: