SwiftUI极简教程32:使用Shape形状和Animation动画创建一个圆形进度条

4,587 阅读6分钟

在本章中,你将学会使用Shape形状和Animation动画创建一个圆形进度条。

1.png

如果你恰巧拥有一块AppleWatch,你一定会注意到AppleWatch上的运动圆环,在你走路、呼吸、爬楼三个方面记录你每天的运动量。

AppleWatch通过圆环进度条的方式告知用户运动进度情况,当你达标后,AppleWatch三个圆环就会闭合。

那么本章节,我们就尝试创建一个类似的圆形进度条。

项目创建

首先,创建一个新项目,命名为SwiftUIProgress 。

2.png

我们先来分析结构。

先来看一个圆,我们发现健身记录的圆环是由2个圆环层叠而成的,我们可以创建2个圆环,然后使用ZStack叠加在一起。

在那之前,因为本次用到的颜色比较多,我们可以将颜色组抽离出来,然后在视图中直接引用,这样做可以使得代码更加简化。

颜色组

我们新建一个Swift文件,命名为ColorExt.swift

import SwiftUI

extension Color {
    public init(red: Int, green: Int, blue: Int, opacity: Double = 1.0) {
        let redValue = Double(red) / 255.0
        let greenValue = Double(green) / 255.0
        let blueValue = Double(blue) / 255.0

        self.init(red: redValue, green: greenValue, blue: blueValue, opacity: opacity)
    }
    public static let gradientPink = Color(red: 210, green: 153, blue: 194)
    public static let gradientYellow = Color(red: 254, green: 249, blue: 215)
}

3.png

我们创建了一个init方法,它接受red红色、green绿色和blue蓝色的值。然后我们初始化Color实例。

这里我们定义了两个颜色,一个是比较好看的粉色gradientPink,一个是比较好看的黄色gradientYellow,我们待会儿要使用这两个颜色作为圆环的渐变颜色。

基础样式-外环

我们回到ContentView主页中,先创建背部的圆环背景。

import SwiftUI

struct ContentView: View {

    var thickness: CGFloat = 30.0
    var width: CGFloat = 250.0

    var body: some View {

        ZStack {
            Circle()
                .stroke(Color(.systemGray6),lineWidth: thickness)
        }
        .frame(width: width, height: width, alignment: .center)
    }
}

4.png

我们首先定义了Circle圆环的厚度thickness和圆环的宽度width,然后使用stroke给圆环描边,边框加个了淡淡的systemGray6灰色。

然后设置圆环的大小。这样我们就获得了第一个圆环:背景圆环。

基础样式-内环

接下来,我们来完成内环,这是一个跟随进度变动的圆环,我们就不能直接用Circle圆形绘制。

还记得之前的章节,我们使用Shape形状绘制各种各样的图形么,这里我们也使用Shape形状的方法绘制内环。

我们创建一个新的结构体,命名为RingShape

//内环
struct RingShape: Shape {

    var progress: Double = 0.0
    var thickness: CGFloat = 30.0
    var startAngle: Double = -90.0

    func path(in rect: CGRect) -> Path {

        var path = Path()

        path.addArc(center: CGPoint(x: rect.width / 2.0, y: rect.height / 2.0), radius: min(rect.width, rect.height) / 2.0,startAngle: .degrees(startAngle),endAngle: .degrees(360 * progress+startAngle), clockwise: false)
        
        return path.strokedPath(.init(lineWidth: thickness, lineCap: .round))
    }
}

我们创建了RingShape结构体,它遵循Shape协议。然后我们和外环一样,定义了它的厚度thickness,另外还有内环的进度百分比progress参数。

startAngle开始角度为-90,这是因为我们圆放在坐标轴上,它的开始点是圆右边中间的位置,而我们进度的圆环是从圆的顶部的顶点开始,所以startAngle开始角度需要设置为-90度。

内环的绘制我们使用addArc的方法,起始角度为0,结束角度用360度乘以progress进度的值,而且要加上startAngle开始角度来计算。

最后返回画好的圆,我们可以在ContentView中引用它看看效果。

ZStack {

    //外环
    Circle()
        .stroke(Color(.systemGray6), lineWidth: thickness)

    //内环
    RingShape(progress: 0.3, thickness: thickness)
}
.frame(width: width, height: width, alignment: .center)

5.png

基础样式-渐变色

接下来,我们给我们的进度圆环附上渐变色Gradient

科普一个知识点。

AngularGradient角梯度,AngularGradient角梯度是SwiftUI提供的一种绘制渐变色的方法,可以跟随不同角度变化,从起点到终点,颜色按顺时针做扇形渐变

AngularGradient(gradient: Gradient(colors: [.gradientPink, .gradientYellow]), center: .center, startAngle: .degrees(startAngle), endAngle: .degrees(360 * 0.3 + startAngle))

这里,我们在AngularGradient角梯度的框架里,指定渲染颜色为渐变色,引用我们定义好的gradientPink粉色和gradientYellow黄色。

渲染梯度开始角度为startAngle定义的开始角度,结束角度为360 * 0.3 + startAngle,从开始角度开始。

然后我们使用.fil修饰符,将AngularGradient角梯度赋予RingShape内环。

6.png

非常不错!

动画效果

接下来,我们来实现下动画效果。

设置3个进度来展示进度:0%、50%、100%,当我们的进度从0%50%时,我们可以看到进度条内环从0~50度的全过程。

我们先定义好初始的进度,替换我们固定的进度值:

@State var progress = 0.0

然后,我们完成下进度值的选择。

//进度调节
HStack {
    Group {

        Text("0%")
            .font(.system(.headline, design: .rounded))
            .onTapGesture {
                self.progress = 0.0
            }

        Text("50%")
            .font(.system(.headline, design: .rounded))
            .onTapGesture {
                self.progress = 0.5
            }

        Text("100%")
            .font(.system(.headline, design: .rounded))
            .onTapGesture {
                self.progress = 1.0
            }
    }
    .padding()
    .background(Color(.systemGray6)).clipShape(RoundedRectangle(cornerRadius: 15.0, style: .continuous))
    .padding()
}
.padding()

我们设置了3个进度值调节,当我们点击0%的进度值时,进度progress赋值0,同理,我们完成了3个进度值选择。

我们将整个进度调节和内外环视图使用VStack垂直排布。

同时,我们在内外环的组合视图中增加Animation动画。

.animation(Animation.easeInOut(duration: 1.0),value: progress)

7.png

嗯?好像出了点问题,我们发现内环的Animation动画好像不起效果,进度加载仍旧很生硬

这是因为我们在实现RingShape内环构建的过程中,它符合Shape协议,而恰巧是Shape协议它有一个默认的动画,也就是没有数据的动画。

因此在ContentView中,我们怎么加Animation动画效果都没有作用。

要解决这个问题也很简单,我们只需要在构建RingShape内环时,赋予progress新的值就可以了。

var animatableData: Double {

    get { progress }
    set { progress = newValue }

}

8.png

这样,我们就实现了内环进度的Animation动画效果。

恭喜你,完成了本章的所有练习~

完整代码

import SwiftUI

struct ContentView: View {

    var thickness: CGFloat = 30.0
    var width: CGFloat = 250.0
    var startAngle = -90.0
    @State var progress = 0.0

    var body: some View {
        VStack {
            ZStack {

                // 外环
                Circle()
                    .stroke(Color(.systemGray6), lineWidth: thickness)

                // 内环
                RingShape(progress: progress, thickness: thickness)
                    .fill(AngularGradient(gradient: Gradient(colors: [.gradientPink, .gradientYellow]), center: .center, startAngle: .degrees(startAngle), endAngle: .degrees(360*0.3 + startAngle)))
            }
            .frame(width: width, height: width, alignment: .center)
            .animation(Animation.easeInOut(duration: 1.0),value: progress)

            //进度调节
            HStack {
                Group {

                    Text("0%")
                        .font(.system(.headline, design: .rounded))
                        .onTapGesture {
                            self.progress = 0.0
                        }

                    Text("50%")
                        .font(.system(.headline, design: .rounded))
                        .onTapGesture {
                            self.progress = 0.5
                        }

                    Text("100%")
                        .font(.system(.headline, design: .rounded))
                        .onTapGesture {
                            self.progress = 1.0
                        }
                }
                .padding()
                .background(Color(.systemGray6)).clipShape(RoundedRectangle(cornerRadius: 15.0, style: .continuous))
                .padding()
            }
            .padding()
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

// 内环
struct RingShape: Shape {

    var progress: Double = 0.0
    var thickness: CGFloat = 30.0
    var startAngle: Double = -90.0

    var animatableData: Double {
        get { progress }
        set { progress = newValue }
    }

    func path(in rect: CGRect) -> Path {

        var path = Path()

        path.addArc(center: CGPoint(x: rect.width / 2.0, y: rect.height / 2.0), radius: min(rect.width, rect.height) / 2.0,startAngle: .degrees(startAngle),endAngle: .degrees(360 * progress + startAngle), clockwise: false)

        return path.strokedPath(.init(lineWidth: thickness, lineCap: .round))
    }
}

快来动手试试吧!

如果本专栏对你有帮助,不妨点赞、评论、关注~