SwiftUI案例:天气
效果
目标
- 实现静态的仿iOS天气APP程序
文件与配置
外观配置
外观配置需要从 '代码' 中下载文件并提取对应的图片
需要配置在
Assets.xcassets
文件中
需要配置在
SpriteFiles/Assets.xcassets
文件中
动态图片导入
在工作区的项目文件夹下创建名为 SpriteFiles
的 Group
并在其中依次导入 RainFall.sks
RainFallLanding.sks
创建View视图
在工作区的项目文件夹下创建名为 View
的 Group
并在其中依次创建 Home.swift
CustomStackView.swift
CustomCorner.swift
WeatherDataView.swift
视图文件
创建Model模板
在工作区的项目文件夹下创建名为 Model
的 Group
并在其中创建 Forecast.swift
视图与模板实现
ContentView.swift
代码语言:javascript复制这是应用视图的总体框架布局,需要自适应屏幕尺寸
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
代码语言:javascript复制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
代码语言:javascript复制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
代码语言:javascript复制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
代码语言:javascript复制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
代码语言:javascript复制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站教程
https://www.bilibili.com/video/BV1664y1X7uJ
代码
WeatherAPPUI.zip
来源:百度网盘 | 提取码:lkob
WeatherAPPUI.zip
来源:蓝奏云网盘 | 提取码:aaca
WeatherAPPUI
来源:Github仓库