今年もクリスマスが近いてきました。
昨年はCore Animationを使ってクリスマスツリーを作成してみました。
https://qiita.com/shiz/items/10cb712a26620f2e3bdc
そこで
今年はSwiftUIを使ってクリスマスツリーを作成したいと思います。
SwiftUIの要素はたくさん使用していますが
drawingGroupに注目したいと思います。
Shapeに適合させツリーのパーツを作成する
まずツリーに必要なパーツを作っていきます。
図形をShapeプロトコルに適合させたstructとして定義して
それを組み合わせてViewを構築します。
星
こちらは下記のサイトを参照させて頂きました。
https://www.hackingwithswift.com/quick-start/swiftui/how-to-draw-polygons-and-stars
pathメソッドの中でPathクラスを生成します。
Pathの指定はUIBezierPathと似たような形で設定できます。
コード
struct Star: Shape {
    let corners: Int
    let smoothness: CGFloat
    func path(in rect: CGRect) -> Path {
        guard corners >= 2 else { return Path() }
        let center = CGPoint(x: rect.width / 2, y: rect.height / 2)
        var currentAngle = -CGFloat.pi / 2
        let angleAdjustment = .pi * 2 / CGFloat(corners * 2)
        let innerX = center.x * smoothness
        let innerY = center.y * smoothness
        var path = Path()
        path.move(to: CGPoint(x: center.x * cos(currentAngle), y: center.y * sin(currentAngle)))
        var bottomEdge: CGFloat = 0
        for corner in 0..<corners * 2  {
            let sinAngle = sin(currentAngle)
            let cosAngle = cos(currentAngle)
            let bottom: CGFloat
            if corner.isMultiple(of: 2) {
                bottom = center.y * sinAngle
                path.addLine(to: CGPoint(x: center.x * cosAngle, y: bottom))
            } else {
                bottom = innerY * sinAngle
                path.addLine(to: CGPoint(x: innerX * cosAngle, y: bottom))
            }
            if bottom > bottomEdge {
                bottomEdge = bottom
            }
            currentAngle += angleAdjustment
        }
        let unusedSpace = (rect.height / 2 - bottomEdge) / 2
        let transform = CGAffineTransform(translationX: center.x, y: center.y + unusedSpace)
        return path.applying(transform)
    }
}
木
次に木の部分です。
木の形の部分
まずは緑の部分の三角形Triangleを定義します。
コード
struct Triangle: Shape {
    func path(in rect: CGRect) -> Path {
        Path { path in
            let middle = rect.midX
            let width: CGFloat = rect.size.width
            let height = rect.height
            path.move(to: CGPoint(x: middle, y: 0))
            path.addLine(to: CGPoint(x: middle + (width / 2), y: height))
            path.addLine(to: CGPoint(x: middle - (width / 2), y: height))
            path.addLine(to: CGPoint(x: middle, y: 0))
        }
    }
}
白い線状の飾り
木の上にある飾りを作成します。
今回はaddQuadCurveを使用して
ちょっと曲線にしています。
コード
struct Slope: Shape {
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.move(to: CGPoint(x: rect.minX, y: rect.midY))
            path.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.maxY),
                              control: CGPoint(x: rect.midX * 0.8, y: rect.midY * 0.8))
        }
    }
}
球状の飾り
次に球状の飾りを作ります。
これはCircleに
GradientとLinierGradientを使って
色をグラデーションしています。
https://developer.apple.com/documentation/swiftui/gradient
https://developer.apple.com/documentation/swiftui/lineargradient
Gradientの初期化時にグラデーションさせたい色を指定します。
変化させる位置を直接指定することもできますが
指定しない場合は
フレームワークで自動で調整してくれるようです。
LinearGradientは
Gradientと開始と終了位置を指定します。
コード
struct BallView: View {
    let gradientColors = Gradient(colors: [Color.pink, Color.purple])
    var body: some View {
        let linearGradient = LinearGradient(
            gradient: gradientColors,
            startPoint: .top, endPoint: .bottom)
        return Circle()
            .fill(linearGradient)
    }
}
そしてこれを複数組み合わせてViewを作ります。
GeometryReaderを使って
Viewの中に規則的にBallViewを配置しています。
三角形をはみ出さないように
maskを使用してBallViewを描画する範囲を限定しています。
https://developer.apple.com/documentation/swiftui/view/3278595-mask
コード
struct BallsSlopeView<Mask: View>: View {
    let drawArea: Mask
    var body: some View {
        GeometryReader { gr in
            ForEach(1...10, id: \.self) { index in
                BallView()
                    .position(
                        self.getPosition(at: index,
                                         midX: gr.frame(in: .local).maxX,
                                         midY: gr.frame(in: .local).maxY))
                    .frame(height: 20)
            }
        }
        .mask(drawArea)
    }
    private func getPosition(at index: Int, midX: CGFloat, midY: CGFloat) -> CGPoint{
        let x = midX * CGFloat(1 - CGFloat(index) * 0.1)
        let y = midY * CGFloat(1 - CGFloat(index) * 0.05)
        return CGPoint(x: x, y: y)
    }
}
組み合わせる
最後に上記で作った部品を組み合わせます。
すべてをZStackでグループにして重ねます。
白い飾りの部分では
rotationEffectを活用することで
少し回転させて木にかかっているようにしています。
コード
struct TreeView: View {
    var body: some View {
        GeometryReader { gr in
            ZStack(alignment: .center) {
                Triangle()
                    .foregroundColor(Color.green)
                Slope()
                    .stroke(lineWidth: 20)
                    .mask(Triangle())
                    .foregroundColor(Color.white)
                Slope()
                    .stroke(lineWidth: 20)
                    .rotationEffect(Angle.degrees(300))
                    .mask(Triangle())
                    .foregroundColor(Color.white)
                BallsSlopeView(drawArea: Triangle())
            }
        }
    }
}
土台
次に土台の部分を作ります。
Rectangleの中に白い線のShapeを載せます。
白い線はShapeで作成します。
コード
struct FoundationLine: Shape {
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.move(to: CGPoint(x: 0, y: rect.maxY / 3))
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY / 3))
            path.move(to: CGPoint(x: 0, y: rect.maxY * 2 / 3))
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY * 2 / 3))
            path.move(to: CGPoint(x: rect.width / 3, y: 0))
            path.addLine(to: CGPoint(x: rect.width / 3, y: rect.maxY))
            path.move(to: CGPoint(x: rect.width * 2 / 3, y: 0))
            path.addLine(to: CGPoint(x: rect.width * 2 / 3, y: rect.maxY))
        }
    }
}
上記で作った白い線とRectangleをZStackでグループにします。
コード
struct FoundationView: View {
    var body: some View {
        GeometryReader { gr in
            ZStack {
                Rectangle()
                    .foregroundColor(Color.red)
                FoundationLine()
                    .stroke(lineWidth: 3)
                    .foregroundColor(Color.white)
                    .mask(Rectangle())
            }
            .frame(width: gr.size.width / 3, height: gr.size.width / 3)
        }
    }
}
クリスマスツリーを組み立てる
ではこれまで作ったものを組み合わせます。
星と個々の木が少しづつ重なるように位置の調整をしています。
また
そのままですと
木の三角の重なり方が
逆になってしまう(上の頂点の部分が上に重なって見える)ため
zIndexで重なり方を変更しています。
https://developer.apple.com/documentation/swiftui/view/3278679-zindex
コード
struct ChristmasTree: View {
    var body: some View {
        GeometryReader { gr in
            VStack(spacing: -12) {
                VStack(spacing: -(gr.size.width * 0.1)) {
                    Star(corners: 5, smoothness: 0.5)
                        .foregroundColor(Color.yellow)
                        .frame(width: gr.size.width * 0.3,
                               height: gr.size.width * 0.3)
                        .zIndex(2)
                    ZStack {
                        VStack(spacing: -(gr.size.width / 5)) {
                            TreeView()
                                .frame(width: gr.size.width * 0.6)
                                .zIndex(3)
                            TreeView()
                                .frame(width: gr.size.width * 0.7)
                                .zIndex(2)
                            TreeView()
                                .frame(width: gr.size.width * 0.8)
                                .zIndex(1)
                        }
                        .frame(height: gr.size.height * 0.5)
                        .foregroundColor(Color.green)
                    }
                    .zIndex(1)
                }
                FoundationView()
                    .frame(height: gr.size.height * 0.2)
            }
        }
    }
}
背景
次に背景を作成していきます。
Circleをランダムな大きさとopacityと位置に配置します。
コード
struct Particles: View {
    var body: some View {
        GeometryReader { gr in
            ZStack {
                ForEach(0...200, id: \.self) { _ in
                    Circle()
                        .foregroundColor(.red)
                        .opacity(.random(in: 0.1...0.4))
                        .frame(width: .random(in: 10...100),
                               height: .random(in: 10...100))
                        .position(x: .random(in: 0...gr.size.width),
                                  y: .random(in: 0...gr.size.height))
                }
            }
        }
    }
}
背景にアニメーションを設定する
せっかくなので背景にアニメーションをつけて
もう少し豪華(?)にしてみます。
今回はinteractiveSpringというAnimationを利用しました。
※
値は色々と触ってみて
こんな感じなのかなと思った値を設定しているので
適当です。
画面表示時にアニメーションを起こすための処理
SwiftUIのアニメーションを設定する上で注意したい点として
単純にアニメーションを設定しただけでは
アニメーションが起動しません。
これを画面表示時に発生させるためには
例えば@Stateを付けたの変数を
onAppearの中で変更することで
Viewの中の値を動的に変更させて再レンダリングさせるなどの
処理が必要になります。
コード
struct Particles: View {
    // レンダリングを起こすために必要
    @State private var scaling = false
    var animation: Animation {
        Animation
            .interactiveSpring(response: 5, dampingFraction: 0.5)
            .repeatForever()
            .speed(.random(in: 0.05...0.9))
            .delay(.random(in: 0...2))
    }
    var body: some View {
        GeometryReader { gr in
            ZStack {
                ForEach(0...200, id: \.self) { _ in
                    Circle()
                        .foregroundColor(.red)
                        .opacity(.random(in: 0.1...0.4))
                        .animation(self.animation)
                        // この値を変化させることで再レンダリングを起こしている
                        .scaleEffect(self.scaling ? .random(in: 0.1...2) : 1)
                        .frame(width: .random(in: 10...100),
                               height: .random(in: 10...100))
                        .position(x: .random(in: 0...gr.size.width),
                                  y: .random(in: 0...gr.size.height))
                }
            }
            .onAppear {
                // レンダリングを起こすために必要
                self.scaling = true
            }
        }
    }
}
パフォーマンスを向上させる
画面としては上記で完成ですが
一つ問題があります。
上記のアニメーションの処理を行ったことによって
メモリの使用量がどんどん増えていきます。
※ この後もずっと増えていきます。
これは
ZStackの中の各Viewが描画をする際に
それぞれでレイヤーを構築します。
そうするとその分のメモリを使用する結果
CPUへの負荷大きくなります。
これはアプリのパフォーマンスの低下を招くことがあります。
そこでdrawingGroupを使って負荷を減らすことができます。
drawingGroup
このメソッドは
Viewの中の全てのViewを
画面上には見えないオフスクリーン上で
Metal APIを使用して
一つのイメージにまとめて描画し
最終的な内容を画面に出力するようにしてくれます。
こうすることでメモリへの負荷を軽減させて
パフォーマンスを向上させることができます。
コード
struct Particles: View {
    // レンダリングを起こすために必要
    @State private var scaling = false
    var animation: Animation {
        Animation
            .interactiveSpring(response: 5, dampingFraction: 0.5)
            .repeatForever()
            .speed(.random(in: 0.05...0.9))
            .delay(.random(in: 0...2))
    }
    var body: some View {
        GeometryReader { gr in
            ZStack {
                ForEach(0...200, id: \.self) { _ in
                    Circle()
                        .foregroundColor(.red)
                        .opacity(.random(in: 0.1...0.4))
                        .animation(self.animation)
                        // この値を変化させることで再レンダリングを起こしている
                        .scaleEffect(self.scaling ? .random(in: 0.1...2) : 1)
                        .frame(width: .random(in: 10...100),
                               height: .random(in: 10...100))
                        .position(x: .random(in: 0...gr.size.width),
                                  y: .random(in: 0...gr.size.height))
                }
            }
            // ここに設定をする
            .drawingGroup()
            .onAppear {
                // レンダリングを起こすために必要
                self.scaling = true
            }
        }
    }
}
※
一つ注意点として
drawingGroupのドキュメントに下記のような記載あります。
Views backed by native platform views don’t render into the image.
native platform viewsには
drawingGroupは効果がないようです。
このnative platform viewsとは
何を指すのかわからなかったのですが
twitter上でAppleの方が回答されていた内容によると
NSViewやUIViewのことのようです。
まとめ
アドベントカレンダーのネタとして
クリスマスツリーを作っていく中で
SwiftUIの機能についていくつか見ていきました。
SwiftUIは宣言的に小さい部品を作り
それを組み合わせていくことができるので
再利用性が高く読みやすいなと改めて感じました。





