CAShapeLayerパスをスムーズにアニメーション化

Aug 23 2020

次の画像をできるだけ模倣するようにゲージUIViewを作成しようとしています

    func gradientBezierPath(percent: CGFloat) ->  UIBezierPath {
        // vary this to move the start of the arc
        let startAngle = CGFloat(180).toRadians()//-CGFloat.pi / 2  // This corresponds to 12 0'clock
        // vary this to vary the size of the segment, in per cent
        let proportion = CGFloat(50 * percent)
        let centre = CGPoint (x: self.frame.size.width / 2, y: self.frame.size.height / 2)
        let radius = self.frame.size.height/4//self.frame.size.width / (CGFloat(130).toRadians())
        let arc = CGFloat.pi * 2 * proportion / 100 // i.e. the proportion of a full circle

        // Start a mutable path
        let cPath = UIBezierPath()
        // Move to the centre
        cPath.move(to: centre)
        // Draw a line to the circumference
        cPath.addLine(to: CGPoint(x: centre.x + radius * cos(startAngle), y: centre.y + radius * sin(startAngle)))
        // NOW draw the arc
        cPath.addArc(withCenter: centre, radius: radius, startAngle: startAngle, endAngle: arc + startAngle, clockwise: true)
        // Line back to the centre, where we started (or the stroke doesn't work, though the fill does)
        cPath.addLine(to: CGPoint(x: centre.x, y: centre.y))
        
        return cPath
    }
    
    override func draw(_ rect: CGRect) {
        
//        let endAngle = percent == 1.0 ? 0 : (percent * 180) + 180
        
        path = UIBezierPath(arcCenter: CGPoint(x: self.frame.size.width/2, y: self.frame.size.height/2),
                            radius: self.frame.size.height/4,
                            startAngle: CGFloat(180).toRadians(),
                            endAngle: CGFloat(0).toRadians(),
                            clockwise: true)
        
        percentPath = UIBezierPath(arcCenter: CGPoint(x: self.frame.size.width/2, y: self.frame.size.height/2),
                                   radius: self.frame.size.height/4,
                                   startAngle: CGFloat(180).toRadians(),
                                   endAngle: CGFloat(0).toRadians(),
                                   clockwise: true)
        
        let shapeLayer = CAShapeLayer()
        shapeLayer.path = self.path.cgPath
        shapeLayer.strokeColor = UIColor(red: 110 / 255, green: 78 / 255, blue: 165 / 255, alpha: 1.0).cgColor
        shapeLayer.fillColor = UIColor.clear.cgColor
        shapeLayer.lineWidth = 5.0
        shapeLayer.lineCap = .round
        self.layer.addSublayer(shapeLayer)
        
        percentLayer.path = self.percentPath.cgPath
        percentLayer.strokeColor = UIColor(red: 255 / 255, green: 93 / 255, blue: 41 / 255, alpha: 1.0).cgColor
        percentLayer.fillColor = UIColor.clear.cgColor
        percentLayer.lineWidth = 8.0
//        percentLayer.strokeEnd = CGFloat(percent)
        percentLayer.lineCap = .round
        self.layer.addSublayer(percentLayer)
        
        // n.b. as @MartinR points out `cPath.close()` does the same!

        // circle shape
        circleShape.path = gradientBezierPath(percent: 1.0).cgPath//cPath.cgPath
        circleShape.strokeColor = UIColor.clear.cgColor
        circleShape.fillColor = UIColor.green.cgColor
        self.layer.addSublayer(circleShape)
        
        gradient.frame = frame
        gradient.mask = circleShape
        gradient.type = .radial
        gradient.colors = [UIColor(red: 255 / 255, green: 93 / 255, blue: 41 / 255, alpha: 0.0).cgColor,
                           UIColor(red: 255 / 255, green: 93 / 255, blue: 41 / 255, alpha: 0.0).cgColor,
                           UIColor(red: 255 / 255, green: 93 / 255, blue: 41 / 255, alpha: 0.4).cgColor]
        gradient.locations = [0, 0.35, 1]
        gradient.startPoint = CGPoint(x: 0.49, y: 0.55) // increase Y adds more orange from top to bottom
        gradient.endPoint = CGPoint(x: 0.98, y: 1) // increase x pushes orange out more to edges
        self.layer.addSublayer(gradient)

        //myTextLayer.string = "\(Int(percent * 100))"
        myTextLayer.backgroundColor = UIColor.clear.cgColor
        myTextLayer.foregroundColor = UIColor.white.cgColor
        myTextLayer.fontSize = 85.0
        myTextLayer.frame = CGRect(x: (self.frame.size.width / 2) - (self.frame.size.width/8), y: (self.frame.size.height / 2) - self.frame.size.height/8, width: 120, height: 120)
        self.layer.addSublayer(myTextLayer)
        
    }

これは、私が目指しているものにかなり近い遊び場で次のものを生成します:

問題は、ゲージ値の変化をアニメーション化しようとするときに発生します。をpercentLayer変更することで非常に簡単にアニメーション化できますが、ゲージのパーセント値に大きな変化がある場合、グラデーションのstrokeEndアニメーションはcircleShape.path滑らかでないアニメーションになります。これが私が両方のレイヤーをアニメーション化するために使用する関数です(ゲージ値の変化をシミュレートするために、現在2秒ごとにタイマーで呼び出されます)。

    func randomPercent() {
        let random = CGFloat.random(in: 0.0...1.0)
        
        // Animate the percent layer
        let animation = CABasicAnimation(keyPath: "strokeEnd")
        animation.fromValue = percentLayer.strokeEnd
        animation.toValue = random
        animation.duration = 1.5
        percentLayer.strokeEnd = random
        percentLayer.add(animation, forKey: nil)
        
        // Animate the gradient layer
        let newShapePath = gradientBezierPath(percent: random)
        let gradientAnimation = CABasicAnimation(keyPath: "path")
        gradientAnimation.duration = 1.5
        gradientAnimation.toValue = newShapePath
        gradientAnimation.fromValue = circleShape.path
        circleShape.path = newShapePath.cgPath
        self.circleShape.add(gradientAnimation, forKey: nil)
        
        myTextLayer.string = "\(Int(random * 100))"
    }

値を少し変更してアニメーションを実行すると、アニメーションの見栄えが良くなることに注目してください。ただし、大きな変更があると、グラデーションアニメーションはまったく自然に見えません。これを改善する方法についてのアイデアはありますか?または、パフォーマンスを向上させるために別のkeyPathをアニメーション化することは可能ですか?どんな助けでも大歓迎です!

回答

2 DuncanC Aug 23 2020 at 08:19

ベジェパスaddArc()関数を使用して円弧をアニメーション化し、円弧の距離を変更することはできません。

問題はコントロールポイントです。アニメーションがスムーズに機能するためには、開始シェイプと終了シェイプのコントロールポイントの数とタイプが同じである必要があります。裏で、UIBezierPath(およびCGPath)オブジェクトは、ベジェ曲線を組み合わせて円を近似する円弧を作成します(2次または3次ベジェ曲線を使用するかどうかは覚えていません)。円全体は、複数の接続されたベジェ曲線( "ベジェ「ベジェパスを含むことができる形状を作成するUIKit関数であるUIBeizerPathではなく、数学的なスプライン関数。)円のベジェ近似は4つのリンクされた3次ベジェ曲線で構成されていることを覚えているようです。(興味がある場合は、このSOの回答を参照してください。)

これがどのように機能するかについての私の理解です。(詳細が間違っている可能性がありますが、いずれにせよ問題を示しています。)完全な円の<= 1/4から> 1/4に移動すると、円弧関数は最初の1立方ベジェを使用します。セクション、次に2。<= 1/2の円から> 1/2の円への遷移では、3つのベジェ曲線にシフトし、<= 3/4の円から> 3への遷移ではシフトします。円の/ 4、それは4ベジェ曲線に切り替わります。

ソリューション:

あなたはstrokeEndを使用して正しい軌道に乗っています。常にあなたの円の一部を与える1未満のものにあなたの完全な円として形状、およびセットstrokeEndを作成するのではなく、方法であなたはそれをすることができますスムーズにアニメーション化します。(strokeStartをアニメートすることもできます。)

CAShapeLayerとstrokeEndを使用して説明したように、円をアニメーション化しました(数年前だったので、Objective-Cでした)。UIImageViewでマスクをアニメーション化するアプローチの使用に関するOSに関する記事をここに書きました。「クロックワイプ」アニメーションを作成します。完全に影付きの円の画像がある場合は、ここでその正確なアプローチを使用できます。(UIViewのコンテンツレイヤーまたは他のレイヤーにマスクレイヤーを追加し、時計のワイプデモのようにそのレイヤーをアニメーション化できるはずです。Objective-Cの解読についてサポートが必要な場合はお知らせください。

これが私が作成したサンプルの時計ワイプアニメーションです:

この効果を使用して、画像ビューだけでなく、任意のレイヤーをマスクできることに注意してください。

編集:プロジェクトのSwiftバージョンで時計ワイプアニメーションの質問と回答の更新を投稿しました。

新しいリポジトリには、次のURLから直接アクセスできます。 https://github.com/DuncanMC/ClockWipeSwift。

あなたのアプリケーションのために、私はあなたがレイヤーの複合体としてアニメートする必要があるあなたのゲージの部分をセットアップします。次に、CAShapeLayerベースのマスクレイヤーをその複合レイヤーにアタッチし、そのシェイプレイヤーに円弧パスを追加して、サンプルプロジェクトに示すようにstrokeEndをアニメーション化します。私の時計ワイプアニメーションは、レイヤーの中心から時計の針をスイープするような画像を表示します。この場合、円弧をレイヤーの下部中央に配置し、シェイプレイヤーでは半円弧のみを使用します。そのようにマスクを使用すると、合成レイヤーに鋭いエッジのトリミングが得られます。赤い弧の丸いエンドキャップを失います。これを修正するには、赤い円弧を独自のシェイプレイヤーとしてアニメーション化し(strokeEndを使用)、グラデーション塗りつぶしの円弧strokeEndを個別にアニメーション化する必要があります。