Как правильно анимировать UIBezierPath, чтобы получить эффект воды / волны? - PullRequest
1 голос
/ 19 марта 2020

Я пытаюсь сделать UIBezierPath, который оживляет как волна или вода. Похоже на что-то вроде этого. https://dribbble.com/shots/3994990-Waves-Loading-Animation

Я использую эту анимацию как своего рода линейный график с точками данных (0-100). Я правильно нарисовал траекторию, но у меня возникли проблемы с ее анимацией.

В настоящее время она выглядит так: https://imgur.com/a/QQX4DGo с суперскоростным / быстрым движением

let dataPoints: [Double]

var displayLink: CADisplayLink?
var startTime: CFAbsoluteTime?

let background: UIView = {
    let view = UIView()
    return view
}()

let shapeLayer: CAShapeLayer = {
    let layer = CAShapeLayer()
    return layer
}()

init(frame: CGRect, data: [Double], precip: [String]) {
    self.dataPoints = data
    super.init(frame: frame)
    addSubview(background)
    background.anchor(top: topAnchor, left: leftAnchor, bottom: bottomAnchor, right: rightAnchor, paddingTop: 0, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, width: 0, height: 0)
}

override func layoutSubviews() {
    super.layoutSubviews()
    background.layer.addSublayer(shapeLayer)
    shapeLayer.strokeColor = UIColor.waterColor.cgColor
    shapeLayer.fillColor = UIColor.waterColor.cgColor
    startDisplayLink()
}

func wave(at elapsed: Double) -> UIBezierPath {
    let maxX = bounds.width
    let maxY = bounds.height

    func f(_ y: Double) -> CGFloat {
        let random = CGFloat.random(in: 1.0...5.0)
        return CGFloat(y) + sin(CGFloat(elapsed/2) * random * .pi)
    }

    func z(_ x: CGFloat) -> CGFloat {
        let random = CGFloat.random(in: 1.0...2.0)

        let position = Int.random(in: 0...1)
        if(position == 0) {
            return x + random
        } else {
            return x - random
        }
    }

    let path = UIBezierPath()
    path.move(to: CGPoint(x: 0, y: maxY))

    let steps = bounds.width/CGFloat(24)
    var start: CGFloat = steps
    for i in 0..<24 {

        let x = z(start)
        let y = maxY - f(dataPoints[i]*100)

        let point = CGPoint(x: x, y: y)
        path.addLine(to: point)

        start+=steps
    }
    path.close()
    return path
}

func startDisplayLink() {
    startTime = CFAbsoluteTimeGetCurrent()
    displayLink?.invalidate()
    displayLink = CADisplayLink(target: self, selector:#selector(handleDisplayLink(_:)))
    displayLink?.add(to: .current, forMode: .common)
    displayLink?.preferredFramesPerSecond = 11
}

func stopDisplayLink() {
    displayLink?.invalidate()
    displayLink = nil
}

@objc func handleDisplayLink(_ displayLink: CADisplayLink) {
    let elapsed = CFAbsoluteTimeGetCurrent() - startTime!
    shapeLayer.path = wave(at: elapsed).cgPath
}

1 Ответ

3 голосов
/ 19 марта 2020

Пара наблюдений:

  1. Вы звоните random внутри f(_:) и z(_:). Это означает, что каждый раз, когда вы вызываете любой из них, вы будете каждый раз получать разные случайные значения. Так что он будет дико прыгать.

    Вы хотите переместить их в константы, определить случайные параметры один раз и использовать эти же факторы с этого момента.

  2. Вы обновляете со скоростью 11 кадров в секунду (кадр / с). Если вы хотите, чтобы он был плавным, оставьте это на устройстве по умолчанию.

  3. Вы рендерите 24 пункта. Если вы не начнете использовать кривые Безье (как предусмотрено в моем ответе на ваш другой вопрос ), это приведет к очень коротким выводам. Я бы увеличил это (например, на iPhone, 200 дает довольно гладкую волну).

  4. Вам не хватает линии в нижнем правом углу вида, прямо перед тем, как вы закроете путь.

  5. Ваша волновая функция не совсем верна, возвращая x плюс функцию синуса (которая даст диагональную волну). Кроме того, если вы хотите, чтобы он ощущался как волны, я бы не только изменял амплитуду волны в зависимости от прошедшего времени, но и также менял бы общую приливную высоту. Например,

    let maxAmplitude: CGFloat = 0.1
    let maxTidalVariation: CGFloat = 0.1
    let amplitudeOffset = CGFloat.random(in: -0.5 ... 0.5)
    let amplitudeChangeSpeedFactor = CGFloat.random(in: 4 ... 8)
    
    let defaultTidalHeight: CGFloat = 0.50
    let saveSpeedFactor = CGFloat.random(in: 4 ... 8)
    
    func wave(at elapsed: Double) -> UIBezierPath {
        func f(_ x: Double) -> CGFloat {
            let elapsed = CGFloat(elapsed)
            let amplitude = maxAmplitude * abs(fmod(CGFloat(elapsed/2), 3) - 1.5)
            let variation = sin((elapsed + amplitudeOffset) / amplitudeChangeSpeedFactor) * maxTidalVariation
            let value = sin((elapsed / saveSpeedFactor + CGFloat(x)) * 4 * .pi)
            return value * amplitude / 2 * bounds.height + (defaultTidalHeight + variation) * bounds.height
        }
    
        let path = UIBezierPath()
        path.move(to: CGPoint(x: bounds.minX, y: bounds.maxY))
    
        for dataPoint in dataPoints {
            let x = CGFloat(dataPoint) * bounds.width + bounds.minX
            let y = bounds.maxY - f(dataPoint)
            let point = CGPoint(x: x, y: y)
            path.addLine(to: point)
        }
        path.addLine(to: CGPoint(x: bounds.maxX, y: bounds.maxY))
        path.close()
        return path
    }
    
  6. Обратите внимание, что документация для CFAbsoluteTimeGetCurrent предупреждает нас, что

    Повторные вызовы этой функции не гарантируют монотонно увеличивающиеся результаты.

    Я бы предложил вместо этого использовать CACurrentMediaTime.

  7. Я бы предложил потерять dataPoints в целом , Это не имеет значения. Я просто go впереди и вычислю dataPoint по ширине вида.

  8. Я бы придерживался стандарта init(frame:). Таким образом, вы можете как добавить представление программно, так и добавить его непосредственно в Интерфейсном Разработчике.

  9. Не забудьте аннулировать вашу ссылку для отображения в deinit.

Таким образом:

@IBDesignable
class WavyView: UIView {

    private weak var displayLink: CADisplayLink?
    private var startTime: CFTimeInterval = 0
    private let maxAmplitude: CGFloat = 0.1
    private let maxTidalVariation: CGFloat = 0.1
    private let amplitudeOffset = CGFloat.random(in: -0.5 ... 0.5)
    private let amplitudeChangeSpeedFactor = CGFloat.random(in: 4 ... 8)

    private let defaultTidalHeight: CGFloat = 0.50
    private let saveSpeedFactor = CGFloat.random(in: 4 ... 8)

    private lazy var background: UIView = {
        let background = UIView()
        background.translatesAutoresizingMaskIntoConstraints = false
        background.layer.addSublayer(shapeLayer)
        return background
    }()

    private let shapeLayer: CAShapeLayer = {
        let shapeLayer = CAShapeLayer()
        shapeLayer.strokeColor = UIColor.waterColor.cgColor
        shapeLayer.fillColor = UIColor.waterColor.cgColor
        return shapeLayer
    }()

    override init(frame: CGRect = .zero) {
        super.init(frame: frame)

        configure()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)

        configure()
    }

    override func willMove(toSuperview newSuperview: UIView?) {
        super.willMove(toSuperview: newSuperview)

        if newSuperview == nil {
            displayLink?.invalidate()
        }
   }

    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()

        shapeLayer.path = wave(at: 0)?.cgPath
    }
}

private extension WavyView {

    func configure() {
        addSubview(background)
        background.anchor(top: topAnchor, left: leftAnchor, bottom: bottomAnchor, right: rightAnchor, paddingTop: 0, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, width: 0, height: 0)

        startDisplayLink()
    }

    func wave(at elapsed: Double) -> UIBezierPath? {
        guard bounds.width > 0, bounds.height > 0 else { return nil }

        func f(_ x: CGFloat) -> CGFloat {
            let elapsed = CGFloat(elapsed)
            let amplitude = maxAmplitude * abs(fmod(elapsed / 2, 3) - 1.5)
            let variation = sin((elapsed + amplitudeOffset) / amplitudeChangeSpeedFactor) * maxTidalVariation
            let value = sin((elapsed / saveSpeedFactor + x) * 4 * .pi)
            return value * amplitude / 2 * bounds.height + (defaultTidalHeight + variation) * bounds.height
        }

        let path = UIBezierPath()
        path.move(to: CGPoint(x: bounds.minX, y: bounds.maxY))

        let count = Int(bounds.width / 10)

        for step in 0 ... count {
            let dataPoint = CGFloat(step) / CGFloat(count)
            let x = dataPoint * bounds.width + bounds.minX
            let y = bounds.maxY - f(dataPoint)
            let point = CGPoint(x: x, y: y)
            path.addLine(to: point)
        }
        path.addLine(to: CGPoint(x: bounds.maxX, y: bounds.maxY))
        path.close()
        return path
    }

    func startDisplayLink() {
        startTime = CACurrentMediaTime()
        displayLink?.invalidate()
        let displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLink(_:)))
        displayLink.add(to: .main, forMode: .common)
        self.displayLink = displayLink
    }

    func stopDisplayLink() {
        displayLink?.invalidate()
    }

    @objc func handleDisplayLink(_ displayLink: CADisplayLink) {
        let elapsed = CACurrentMediaTime() - startTime
        shapeLayer.path = wave(at: elapsed)?.cgPath
    }
}

То есть:

enter image description here

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...