目 录CONTENT

文章目录

SwiftUI案例:天气

Dioxide-CN
2022-04-22 / 1 评论 / 6 点赞 / 264 阅读 / 10,738 字
温馨提示:
本文最后更新于 2022-04-22,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

SwiftUI案例:天气

效果

show-5

目标

  • 实现静态的仿iOS天气APP程序

文件与配置

外观配置


配置1

需要配置在 Assets.xcassets 文件中

配置2

需要配置在 SpriteFiles/Assets.xcassets 文件中

动态图片导入

在工作区的项目文件夹下创建名为 SpriteFilesGroup 并在其中依次导入 RainFall.sks RainFallLanding.sks

创建View视图

在工作区的项目文件夹下创建名为 ViewGroup 并在其中依次创建 Home.swift CustomStackView.swift CustomCorner.swift WeatherDataView.swift 视图文件

创建Model模板

在工作区的项目文件夹下创建名为 ModelGroup 并在其中创建 Forecast.swift

视图与模板实现

ContentView.swift

这是应用视图的总体框架布局,需要自适应屏幕尺寸

import SwiftUI

struct ContentView: View {
    var body: some View {
        //需要通过proxy的geometry reader来获得屏幕合适的尺寸
        GeometryReader { proxy in
            //获得顶部距离
            let topEdge = proxy.safeAreaInsets.top
            //使用Home()视图
            Home(topEdge: topEdge)
                .ignoresSafeArea(.all, edges: .top)
        }
    }
}

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

Home.swift

import SwiftUI
import SpriteKit

struct Home: View {
    @State var offset: CGFloat = 0 //offset偏量
    var topEdge: CGFloat //topEdge顶部边缘距离
    //避免过早显示"启动动画"将推迟该动画的实现
    @State var showRain = true
    var body: some View {
        ZStack {
            //GeometryReader设置容器布局
            GeometryReader { proxy in
                Image("sky")
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .frame(width: proxy.size.width, height: proxy.size.height, alignment: .center)
            }
            .ignoresSafeArea()
            .overlay(.ultraThinMaterial)
            //"雨点动画"视图
            GeometryReader{ _ in
                SpriteView(scene: RainFall(), options: [.allowsTransparency])
            }
            .ignoresSafeArea()
            .opacity(showRain ? 1 : 0)
            //主视图布局
            ScrollView(.vertical, showsIndicators: false) {
                //使用纵向布局
                VStack {
                    //顶部天气数据
                    VStack(alignment: .center, spacing: 5) {
                        Text("常州市")
                            .font(.system(size: 25))
                            .foregroundColor(.white)
                            .shadow(radius: 5)
                        
                        Text(" 25° ")
                            .font(.system(size: 45))
                            .foregroundColor(.white)
                            .shadow(radius: 5)
                            .opacity(getTitleOpactiy())
                        
                        Text("大部多云")
                            .foregroundStyle(.secondary)
                            .foregroundColor(.white)
                            .shadow(radius: 5)
                            .opacity(getTitleOpactiy())
                        
                        Text("最高 30° 最低 16°")
                            .foregroundStyle(.primary)
                            .foregroundColor(.white)
                            .shadow(radius: 5)
                            .opacity(getTitleOpactiy())
                    }
                    .offset(y: -offset) //y轴偏量
                    //跟随底部滑动距离设置偏量
                    .offset(y: offset > 0 ? (offset / UIScreen.main.bounds.width) * 100 : 0)
                    .offset(y: getTitleOffset())
                    //纵向布局"逐小时预报"
                    VStack(spacing: 8) {
                        //使用自定义的CustomStackView组件
                        CustomStackView {
                            //Label文字内容
                            Label {
                                Text("多云将持续一整天。")
                            } icon: {
                                Image(systemName: "cloud") //图标
                            }
                        } contentView: {
                            //"逐小时预报"容器的内容设置
                            ScrollView(.horizontal, showsIndicators: false) {
                                HStack(spacing: 15) {
                                    //调用ForecastView结构体来设置天气
                                    ForecastView(time:"现在", celcius: 25, image: "cloud")
                                    ForecastView(time:"10时", celcius: 27, image: "cloud")
                                    ForecastView(time:"11时", celcius: 28, image: "cloud")
                                    ForecastView(time:"12时", celcius: 29, image: "cloud")
                                    ForecastView(time:"13时", celcius: 29, image: "cloud")
                                    ForecastView(time:"14时", celcius: 29, image: "cloud")
                                    ForecastView(time:"15时", celcius: 29, image: "cloud")
                                }
                            }
                        }
                        //调用WeatherDataView()组件来布局"WeatherDataView"视图
                        WeatherDataView()
                    }
                    .background {
                        GeometryReader{ _ in
                            SpriteView(scene: RainFallLanding(), options: [.allowsTransparency])
                                .offset(y: -10)
                        }
                        .offset(y: -(offset + topEdge) > 60 ? -(offset + (60 + topEdge)) : 0)
                        .opacity(showRain ? 1 : 0)
                        
                    }
                }
                .padding(.top, 25)
                .padding(.top, topEdge)
                .padding([.horizontal, .bottom])
                //获得offset的值
                .overlay(
                    GeometryReader { proxy -> Color in
                        let minY = proxy.frame(in: .global).minY
                        DispatchQueue.main.async {
                            //Y轴"纵截距"最小值
                            self.offset = minY
                        }
                        return Color.clear
                    }
                )
            }
        }
    }
    //获得标题的透明度
    func getTitleOpactiy() -> CGFloat {
        let titleOffset = -getTitleOffset()
        let progress = titleOffset / 20
        let opacity = 1 - progress
        return opacity //返回透明度
    }

    func  getTitleOffset() -> CGFloat {
        //为整个标题设置一个最大高度,其中:理想的最大值为 120
        if offset < 0 {
            let progress = -offset / 120
            let newOffset = (progress <= 1.0 ? progress : 1) * 20
            return -newOffset
        }
        return 0
    }
}

//全局暴露ContentView()视图容器
struct Home_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

//设置ForecastView结构体
struct ForecastView: View {
    var time: String //时间
    var celcius: CGFloat //温度
    var image: String //图标
    
    var body: some View {
        //设置每个ForecastView的容器视图:采用纵向布局
        VStack(spacing: 15) {
            Text(time) //时间样式
                .font(.callout.bold())
                .foregroundStyle(.white)
            Image(systemName: image) //图标样式
                .font(.title2)
                //使用多色图标
                .symbolVariant(.fill)
                .symbolRenderingMode(.palette)
                .foregroundStyle(.yellow, .white)
                .frame(height: 30)
            Text("\(Int(celcius))°") //温度样式
                .font(.callout.bold())
                .foregroundStyle(.white)
        }
        .padding(.horizontal, 10) //整体内边距
    }
}

//模仿iOS15天气应用创建"下雨/下雪"动画视图
class RainFall: SKScene {
    override func sceneDidLoad() {
        size = UIScreen.main.bounds.size
        scaleMode = .resizeFill
        //固定锚点
        anchorPoint = CGPoint(x: 0.5, y: 1)
        //清除背景颜色
        backgroundColor = .clear
        //创建节点并布局RainFall.sks文件
        let node = SKEmitterNode(fileNamed: "RainFall.sks")!
        addChild(node)
        //全屏尺寸
        node.particlePositionRange.dx = UIScreen.main.bounds.width
    }
}

//模仿iOS15天气应用创建"地面雨点"动画
class RainFallLanding: SKScene {
    override func sceneDidLoad() {
        size = UIScreen.main.bounds.size
        scaleMode = .resizeFill
        let height = UIScreen.main.bounds.height
        //按位置范围获取百分比并固定锚点
        anchorPoint = CGPoint(x: 0.5, y: (height - 5) / height)
        //清除背景颜色
        backgroundColor = .clear
        //创建节点并布局RainFallLanding.sks文件
        let node = SKEmitterNode(fileNamed: "RainFallLanding.sks")!
        addChild(node)
        //为卡片移除内边距
        node.particlePositionRange.dx = UIScreen.main.bounds.width - 30
    }
}

WeatherDataView.swift

import SwiftUI

struct WeatherDataView: View {
    //自定义更多信息视图容器
    var body: some View {
        VStack(spacing: 8) {
            //调用CustomStackView()视图来布局
            //空气质量
            CustomStackView {
                //显式传递Label数据与图标
                Label {
                    Text("空气质量")
                } icon: {
                    Image(systemName: "circle.hexagongrid.fill")
                }
            } contentView: { //显式传入文本信息
                VStack(alignment: .leading, spacing: 20) {
                    Text("73 - 良")
                        .font(.title3.bold())
                    Text("当前AQI(CN)为73。极少数异常敏感人群应减少户外活动。")
                        .fontWeight(.semibold)
                }
                .foregroundStyle(.white)
            }
            HStack {
                //调用CustomStackView()视图来布局
                //紫外线指数
                CustomStackView {
                    Label {
                        Text("紫外线指数")
                    } icon: {
                        Image(systemName: "sun.min")
                    }
                } contentView: {
                    VStack(alignment: .leading, spacing: 10) {
                        Text("4")
                            .font(.title)
                            .fontWeight(.semibold)
                        
                        Text("一般")
                            .font(.title)
                            .fontWeight(.semibold)
                    }
                    .foregroundStyle(.white)
                    .frame(maxWidth:.infinity, alignment: .leading)
                }
                //调用CustomStackView()视图来布局
                //降雨量
                CustomStackView {
                    Label {
                        Text("降雨")
                    } icon: {
                        Image(systemName: "drop.fill")
                    }
                    
                } contentView: {
                    VStack(alignment: .leading, spacing: 10) {
                        Text("0毫米")
                            .font(.title)
                            .fontWeight(.semibold)
                        Text("过去24小时")
                            .font(.title3)
                            .fontWeight(.semibold)
                    }
                    .foregroundStyle(.white)
                    .frame(maxWidth:.infinity, maxHeight:.infinity, alignment: .leading)
                }

            }
            .frame(maxHeight:.infinity)
            //调用CustomStackView()视图来布局
            //未来14日天气预报
            CustomStackView {
                Label {
                    Text("\(forecast.count)日天气预报")
                } icon: {
                    Image(systemName: "calendar")
                }
            } contentView: {
                VStack(alignment: .leading, spacing: 10) {
                    //循环遍历Model/Forecast中的枚举数组并释放值cast变量中
                    ForEach(forecast) { cast in
                        VStack {
                            HStack(spacing: 15) {
                                //从每个cast对象中得到它的数据值并显式地调用它
                                Text(cast.day)
                                    .font(.title3.bold())
                                    .foregroundStyle(.white)
                                    .frame(width: 60, alignment: .leading)
                                Image(systemName: cast.image)
                                    .font(.title3)
                                    .symbolVariant(.fill)
                                    .symbolRenderingMode(.palette)
                                    .foregroundStyle(.yellow, .white)
                                    .frame(width: 30)
                                Text("\(Int(cast.farenheit - 8))")
                                    .font(.title3.bold())
                                    .foregroundStyle(.secondary)
                                    .foregroundStyle(.white)
                                //构造温度进度条
                                ZStack(alignment: .leading) {
                                    Capsule()
                                        .fill(.tertiary)
                                        .foregroundStyle(.white)
                                    GeometryReader { proxy in
                                        Capsule()
                                            .fill(.linearGradient(.init(colors: [.orange, .red]), startPoint: .leading, endPoint: .trailing))
                                            .frame(width: (cast.farenheit / 140) * proxy.size.width)
                                    }
                                }
                                .frame(height:4)
                                Text("\(Int(cast.farenheit))")
                                    .font(.title3.bold())
                                    .foregroundStyle(.secondary)
                                    .foregroundStyle(.white)
                            }
                            Divider()
                        }
                        .padding(.vertical, 8)
                    }
                }
            }
        }
    }
}

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

CustomStackView.swift

import SwiftUI

struct CustomStackView<Title:View, Content:View>: View {
    //全局自定义CustomStackView组件
    //需要显式地传入变量"Title:View"与"Content:View"
    
    var titleView: Title //实例化"Title -> titleView"
    var contentView: Content //实例化"Content -> contentView"
    
    @State var topOffset: CGFloat = 0 //设置顶部偏量
    @State var bottomOffset: CGFloat = 0 //设置底部偏量
    
    //构造初始化函数
    init(@ViewBuilder titleView:@escaping () ->Title, @ViewBuilder contentView:@escaping () ->Content) {
        self.contentView = contentView()
        self.titleView = titleView()
    }
    
    var body: some View {
        //采用纵向布局
        VStack(spacing:0) {
            //标题视图容器:View
            titleView
                .font(.callout)
                .lineLimit(1)
                .frame(height: 38)
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(.leading)
                .background(.ultraThinMaterial, in: CustomCorner(corners: bottomOffset < 38 ? .allCorners : [.topLeft, .topRight], radius: 12))
                .zIndex(1)
            //内容视图:View
            VStack {
                Divider()
                contentView.padding()
            }
            .background(.ultraThinMaterial, in: CustomCorner(corners: [.bottomLeft, .bottomRight], radius: 12))
            
            //向上移动文本内容
            .offset(y: topOffset >= 120 ? 0 : -(-topOffset + 120))
            .zIndex(0)
            //剪裁尺寸大小以避免背景的重叠
            .clipped()
            .opacity(getOpacity())
        }
        .colorScheme(.dark)
        .cornerRadius(12)
        .opacity(getOpacity())
        //在达到120高度的时候停止继续缩小容器高度
        .offset(y: topOffset >= 120 ? 0 : -topOffset + 120)
        .overlay(
            GeometryReader { proxy ->Color in
            let minY = proxy.frame(in: .global).minY
            let maxY = proxy.frame(in: .global).maxY
                DispatchQueue.main.async {
                    self.topOffset = minY
                    //减少至120高度
                    self.bottomOffset = maxY - 120
                    //设置标题高度至最小值:38
                }
                return Color.clear
            }
        )
        .modifier(CornerModifier(bottomOffset: $bottomOffset))
    }
    //获取透明度
    func getOpacity() -> CGFloat {
        if  bottomOffset < 28 {
            let progress = bottomOffset / 28
            return progress
        }
        else {
            return 1
        }
    }
    
}

struct CustomStackView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
    
  
struct CornerModifier: ViewModifier {
    @Binding var bottomOffset: CGFloat
    func body(content: Content) -> some View {
        if bottomOffset < 38 {
            content
        }
        else {
            content.cornerRadius(12)
        }
    }
}

CustomCorner.swift

struct CustomCorner: Shape {
    //自定义圆角样式
    //类似于css中的class
    var corners: UIRectCorner
    var radius: CGFloat
    func path(in rect: CGRect) -> Path {
        let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners,cornerRadii: CGSize(width: radius, height: radius))
        return Path(path.cgPath)
    }
}

Forecast.swift

import SwiftUI
//结构体:未来14天的天气
struct DayForecast: Identifiable {
    var id = UUID().uuidString //随机的UUID
    var day: String //日期
    var farenheit: CGFloat //温度
    var image: String //图标
}

//使用数组静态枚举未来14天的情况
var forecast = [
    DayForecast(day: "今天", farenheit: 16, image: "sun.min"),
    DayForecast(day: "周六", farenheit: 16, image: "cloud.sun"),
    DayForecast(day: "周日", farenheit: 16, image: "cloud.sun.bolt"),
    DayForecast(day: "周一", farenheit: 16, image: "sun.max"),
    DayForecast(day: "周二", farenheit: 16, image: "cloud.sun"),
    DayForecast(day: "周三", farenheit: 16, image: "sun.min"),
    DayForecast(day: "周四", farenheit: 16, image: "sun.max"),
    DayForecast(day: "周五", farenheit: 16, image: "sun.min"),
    DayForecast(day: "周六", farenheit: 16, image: "cloud.sun"),
    DayForecast(day: "周日", farenheit: 16, image: "cloud.sun.bolt"),
    DayForecast(day: "周一", farenheit: 16, image: "sun.max"),
    DayForecast(day: "周二", farenheit: 16, image: "cloud.sun"),
    DayForecast(day: "周三", farenheit: 16, image: "sun.min"),
    DayForecast(day: "周四", farenheit: 16, image: "sun.max"),
    
]

B站教程


代码



6

评论区