项目背景
又到了吃饭的时间了,打开一些餐饮App
翻来翻去都不知道想吃什么,感觉全部都吃过了,看到都有点儿腻。
有没有一个App
能够帮我随机推荐吃什么的呢?想了想,干脆我自己写一个吧!
说干就干。
全文约3500字,预计阅读时长为5分钟,实操时长约15分钟。
项目搭建
首先,创建一个新的SwiftUI
项目,命名为MyMenu
。
Model部分
数据模型
首先是数据部分的准备,我们创建一个新的Swift
文件,命名为Model.swift
。
import SwiftUI
class Model: Decodable {
var foodTime: String
var foodName: String
var foodImageURL: String
}
上述代码中,我们创建了一个Model
类,遵循Decodable
协议。
Decodable
协议可以帮助我们解析来自网络请求中的Json
数据格式,我们声明了3个String
类型的变量:餐段foodTime
、食物名称foodName
、食物图片foodImageURL
。
回到ContentView
文件,使用@State
修饰符声明一个数组存在Model
数据,示例:
@State var models: [Model] = []
Json数据
数据源部分,我们使用第三方网站工具,生成Json
数据,示例:
我们拿到了Json
数据的地址,我们也在ContentView
文件中声明,示例:
let DataURL = "https://api.npoint.io/4e97acfc3e5f73300779"
这样我们就完成了基础的数据准备。
View部分
颜色拓展
为了更好地使用16进制颜色值,我们对Color
进行拓展。创建一个新的Swift
文件,命名为ColorHexString
。
import SwiftUI
extension Color {
static func rgb(_ red: CGFloat, green: CGFloat, blue: CGFloat) -> Color {
return Color(red: red / 255, green: green / 255, blue: blue / 255)
}
static func Hex(_ hex: UInt) -> Color {
let r: CGFloat = CGFloat((hex & 0xFF0000) >> 16)
let g: CGFloat = CGFloat((hex & 0x00FF00) >> 8)
let b: CGFloat = CGFloat(hex & 0x0000FF)
return rgb(r, green: g, blue: b)
}
}
这样我们就可以在接下来的View
页面样式中直接使用16进制颜色值了。
标题
先声明一个变量存储当前餐段信息,后面我们会通过当前时间来判断现在属于哪一个餐段。示例:
var DefaultTime:String = "午餐"
然后我们构建一个标题视图,并在ContentView
视图中展示。示例:
// 标题
func TitleView(time:String) -> some View {
HStack {
Text("当前餐段 : "+time)
.font(.title2)
.fontWeight(.bold)
Spacer()
Image(systemName: "rectangle.grid.1x2.fill")
.foregroundColor(Color.Hex(0x67C23A))
}
.padding(.horizontal)
.padding(.top)
}
上述代码中,我们定义了一个TitleView
方法,传入标题参数,返回View
视图。
我们使用Text
作为标题,使用String
字符串拼接方式展示,另外使用Image
构建了一个切换餐段的图标,之后的交互中会使用。
推荐结果
推荐结果部分由餐品图片和餐品名称组成,我们也声明2个变量存储它,示例:
var DefaultImageURL:String = "https://img0.baidu.com/it/u=156558209,1663147989&fm=253&fmt=auto&app=138&f=JPEG?w=626&h=500"
var DefaultName:String = "今天想吃点啥?"
推荐结果样式部分,我们采用最简单的纵向布局进行组合,示例:
// 推荐结果
func CardView(imageURL: String, name: String) -> some View {
VStack {
AsyncImage(url: URL(string: imageURL))
.aspectRatio(contentMode: .fit)
.frame(minWidth: 120, maxWidth: .infinity, minHeight: 120, maxHeight: .infinity)
Text(name)
.font(.system(size: 17))
.fontWeight(.bold)
.foregroundColor(.black)
.padding()
}
.cornerRadius(10)
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.Hex(0x67C23A), lineWidth: 2))
.padding([.top, .horizontal])
}
上述代码中,我们定义了一个方法CardView
,传入imageURL
、name
,返回一个View
视图。
在CardView
视图中,我们使用AsyncImage
来创建餐品图片,然后使用Text
来展示餐品名称,并且给整个视图overlay
加了边框线。
推荐按钮
同样的方式,我们创建一个推荐按钮,用于随机挑选餐品,先完成样式部分,示例:
// 推荐按钮
func ChooseBtn() -> some View {
Button(action: {
}) {
Text("一键推荐")
.font(.system(size: 17))
.fontWeight(.bold)
.frame(minWidth: 0, maxWidth: .infinity)
.padding()
.foregroundColor(.white)
.background(Color.Hex(0x67C23A))
.cornerRadius(5)
.padding(.horizontal, 20)
.padding(.bottom)
}
}
整体样式效果
效果不错!
接下来才是有趣的地方,敲黑板!开始要写逻辑了!
ViewModel部分
获得当前餐段
我们创建一个新的Swift
文件,命名为ViewModel.swift
。
关于系统获得餐段的思路,我们可以这么考虑,我们先获得当前系统的时间,根据系统时间所处的时间段,来更新餐段。示例:
import SwiftUI
class ViewModel: ObservableObject {
// 当前餐段
@Published var currentTimeName: String = ""
init() {
updateTime()
}
// 餐段枚举
enum MealTimeName: String {
case breakfast = "早餐"
case lunch = "午餐"
case afternoonTea = "下午茶"
case supper = "晚餐"
case nightSnack = "宵夜"
}
// 获取当前系统时间
func getCurrentTime() -> Int {
let dateformatter = DateFormatter()
dateformatter.dateFormat = "HH"
return Int(dateformatter.string(from: Date()))!
}
// 更新当前餐段
func updateTime() {
if getCurrentTime() < 10 {
currentTimeName = MealTimeName.breakfast.rawValue
} else if getCurrentTime() >= 10 && getCurrentTime() < 14 {
currentTimeName = MealTimeName.lunch.rawValue
} else if getCurrentTime() >= 14 && getCurrentTime() < 16 {
currentTimeName = MealTimeName.afternoonTea.rawValue
} else if getCurrentTime() >= 16 && getCurrentTime() < 20 {
currentTimeName = MealTimeName.supper.rawValue
} else {
currentTimeName = MealTimeName.nightSnack.rawValue
}
}
}
上述代码中,我们先声明了一个变量currentTimeName
,来作为更新餐段的参数。
然后设置了一个餐段名称的枚举MealTimeName
,来表示餐段和对应餐段的名称。
再是定义了一个方法getCurrentTime
获得当前时间,只取值到小时,再定义了一个方法updateTime
来根据获得到的时间和一些时间段做比较,更新currentTimeName
的值。
最后在init
调用时调用updateTime
更新方法,就得到了当前的餐段currentTimeName
的准确值。
更新当前餐段
我们回到ContentView
文件中,首先将原先声明的变量DefaultTime
加一个存储方式,示例:
@State var DefaultTime:String = "午餐"
然后引入ViewModel
的内容,示例:
@ObservedObject private var viewModel = ViewModel()
在主视图展示时,更新当前餐段,示例:
.onAppear(){
DefaultTime = viewModel.currentTimeName
}
切换当前餐段
App
除了根据系统时间自动判断餐段外,我们还可以增加一个可供用户手工切换餐段的交互。
我们可以使用Sheet
弹窗来做切换,首先先创建样式部分,示例:
// 切换餐段
private var ChooseTimeSheet: ActionSheet {
let action = ActionSheet(
title: Text("餐段"),message: Text("请选择餐段"),buttons:[
.default(Text("早餐"), action: {self.DefaultTime = "早餐"}),
.default(Text("午餐"), action: {self.DefaultTime = "午餐"}),
.default(Text("下午茶"), action: {self.DefaultTime = "下午茶"}),
.default(Text("晚餐"), action: {self.DefaultTime = "晚餐"}),
.default(Text("宵夜"), action: {self.DefaultTime = "宵夜"}),
.cancel(Text("取消"), action: {})
]
)
return action
}
我们创建了一个Sheet
弹窗,它有几个可选项,当我们点击不同餐段名称时,更新DefaultTime
餐段的值。
Sheet
弹窗样式创建好后,我们声明一个变量来供点击触发,示例:
@State var showChooseTimeSheet: Bool = false
然后在ContentView
视图中调用Sheet
弹窗,示例:
// 选择餐段
.actionSheet(isPresented: $showChooseTimeSheet, content: { ChooseTimeSheet })
至于触发条件,我们加在点击TitleView
标题视图右边的Image
上,示例:
.onTapGesture {
self.showChooseTimeSheet.toggle()
}
不错不错!
网络请求数据
让我们回到ViewModel.swift
文件,我们来完成网络请求部分。
@Published var currentTimeName: String = ""
@Published var currentImageURL: String = ""
@Published var currentName: String = ""
@Published var models: [Model] = []
let DataURL = "https://api.npoint.io/4e97acfc3e5f73300779"
首先,我们要声明好ViewModel
需要的信息,后面在View
中进行赋值,我们声明了餐品图片地址currentImageURL
、餐品名称currentName
、存储的数组models
,还有请求数据的地址DataURL
。
然后是网络请求部分,示例:
// 网络请求
func getMenu() {
let session = URLSession(configuration: .default)
session.dataTask(with: URL(string: DataURL)!) { data, _, _ i
guard let jsonData = data else { return }
do {
let meals = try JSONDecoder().decode([Model].self, from: jsonData)
self.models = meals
} catch {
print(error)
}
}
.resume()
}
上述代码中,我们定义了一个方法getMenu
,通过URLSession
获得数据源地址DataURL
的数据,并且解析到models
中。
这样在调用getMenu
方法时,我们就可以从DataURL
地址中获得Json
格式的数据,并解析数据按照我们Model
声明好的参数进行存储。
筛选餐段数据
下一步,由于我们请求回来的数据是所有餐段的数据,而我们每次App推荐的是单个餐段的数据,那么我们还需要从请求回来的所有数据当中筛选出当前选择的餐段的数据。示例:
// 根据餐段获得餐品信息
func getMealMessage(time:String) {
let query = time.lowercased()
DispatchQueue.global(qos: .background).async {
let filter = self.models.filter { $0.foodTime.lowercased().contains(query) }
DispatchQueue.main.async {
withAnimation(.spring()) {
self.models = filter
}
}
}
}
上述代码中,我们定义了一个方法getMealMessage
,传入String
类型的餐段时间time
,然后将time
作为匹配项,与models
数组中的foodTime
进行匹配关联。
找到餐段时间和数组中的餐段时间一致的数据,就把相关数据重新存储到models
数组中,这样我们根据餐段筛选出来了餐品信息。
随机推荐餐品
我们通过网络请求getMenu
方法获得了所有餐段的餐品数据,再通过getMealMessage
方法根据餐段筛选出来本餐段的数据,下一步就是在这个餐段的数据中随机推荐餐品,示例;
//随机推荐菜品
func getRandomFood() {
let index = Int(arc4random() % UInt32(models.count))
currentName = models[index].foodName
currentImageURL = models[index].foodImageURL
}
上述代码中,我们定义了一个方法getRandomFood
,在方法中,我们从models
数组中的所有数据总量生成一个随机数index
,然后餐品名称currentName
赋值models
数组中随机数index
下标的foodName
,同理餐品图片currentImageURL
也是。
这样我们就得到了一个获得该餐段随机餐品的方法。
我们先在viewModel
初始化时,调用获得餐品数据和根据餐段筛选商品的方法。示例:
init() {
updateTime()
getMenu()
}
ViewModel方法调用
我们回到ContentView.swift
文件,我们在View
中根据业务调用ViewModel
中的方法。
首先,原先声明的变量都需要使用@State
关键字,以便于实现存储。示例:
@State var DefaultTime: String = "午餐"
@State var DefaultImageURL: String = "https://img0.baidu.com/it/u=156558209,1663147989&fm=253&fmt=auto&app=138&f=JPEG?w=626&h=500"
@State var DefaultName: String = "今天想吃点啥?"
然后在ChooseBtn
按钮上添加交互动作,当我们点击一键推荐时,搜索根据当前餐段筛选数据,然后调用随机餐品的方法,最后将餐品名称和餐品图片赋值到View
中,示例:
// 推荐按钮
func ChooseBtn() -> some View {
Button(action: {
viewModel.getMealMessage(time: DefaultTime)
viewModel.getRandomFood()
DefaultImageURL = viewModel.currentImageURL
DefaultName = viewModel.currentName
}) {
Text("一键推荐")
.font(.system(size: 17))
.fontWeight(.bold)
.frame(minWidth: 0, maxWidth: .infinity)
.padding()
.foregroundColor(.white)
.background(Color.Hex(0x67C23A))
.cornerRadius(5)
.padding(.horizontal, 20)
.padding(.bottom)
}
}
当然不要忘了,我们还有切换餐段的功能呢,在切换餐段时,我们还需要重新赋值。示例:
self.DefaultTime = "早餐"
viewModel.getMenu()
DefaultImageURL = "https://img0.baidu.com/it/u=156558209,1663147989&fm=253&fmt=auto&app=138&f=JPEG?w=626&h=500"
DefaultName = "今天想吃点啥?"
上述代码中,当我们切换餐段的时候,除了餐段时间DefaultTime
重新赋值外,我们还调用网络请求重新更新models
数组的数据,以及将餐品名称和餐品图片。
点击按钮预览下效果:
交互动画
动画部分是SwiftUI
的灵魂,承接着用户和App
之间沟通的渠道。
动画部分我们可以做简单一点,比如在推荐时给个加载动画,推荐成功后展示推荐结果。
Loading动画
我们创建一个新的SwiftUI
文件,命名为LoadingView
。
import SwiftUI
struct LoadingView: View {
@State var show: Bool = false
var body: some View {
Image(systemName: "sun.min.fill")
.resizable()
.foregroundColor(Color.Hex(0xFAD0C4))
.aspectRatio(contentMode: .fit)
.frame(width: 60, height: 60)
.rotationEffect(.degrees(show ? 360 : 0))
.onAppear(perform: {
doAnimation()
})
}
func doAnimation() {
withAnimation(Animation.easeInOut(duration: 1).repeatForever(autoreverses: true)) {
show.toggle()
}
}
}
我们创建了一个Image
,然后让它自动旋转,达到加载中的效果。由于之前我们就用过这段代码,这里就做太多的解释了。
交互动画使用
我们回到ContentView.swift
文件,声明一个变量来判断是否展示结果,示例:
@State var showResult: Bool = false
然后根据showResult
的值来展示结果还是加载LoadingView
动画,示例:
if !showResult {
CardView(imageURL: DefaultImageURL, name: DefaultName)
} else {
LoadingView()
}
最后,我们在ChooseBtn
视图点击一键推荐时,进行展示结果的切换,示例:
self.showResult = true
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.0) {
self.showResult = false
DefaultImageURL = viewModel.currentImageURL
DefaultName = viewModel.currentName
}
上述代码中,我们在点击一键推荐时,首先修改showResult
的值,展示Loading
,然后在1秒之后,我们再修改showResult
的值,并赋值重新展示推荐结果。
项目展示
不错不错!
如果本专栏对你有帮助,不妨点赞、评论、关注~
我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。