每天不知道吃什么,用SwiftUI写个随机推荐App帮你做决定吧!

项目背景

又到了吃饭的时间了,打开一些餐饮App翻来翻去都不知道想吃什么,感觉全部都吃过了,看到都有点儿腻。

有没有一个App能够帮我随机推荐吃什么的呢?想了想,干脆我自己写一个吧!

说干就干。

全文约3500字,预计阅读时长为5分钟,实操时长约15分钟

项目搭建

首先,创建一个新的SwiftUI项目,命名为MyMenu

1.png

Model部分

数据模型

首先是数据部分的准备,我们创建一个新的Swift文件,命名为Model.swift

import SwiftUI

class Model: Decodable {
    var foodTime: String
    var foodName: String
    var foodImageURL: String
}

2.png

上述代码中,我们创建了一个Model类,遵循Decodable协议。

Decodable协议可以帮助我们解析来自网络请求中的Json数据格式,我们声明了3个String类型的变量:餐段foodTime、食物名称foodName、食物图片foodImageURL

回到ContentView文件,使用@State修饰符声明一个数组存在Model数据,示例:

@State var models: [Model] = []

Json数据

数据源部分,我们使用第三方网站工具,生成Json数据,示例:

3.png

我们拿到了Json数据的地址,我们也在ContentView文件中声明,示例:

let DataURL = "https://api.npoint.io/4e97acfc3e5f73300779"

4.png

这样我们就完成了基础的数据准备。

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)
    }
}

5.png

这样我们就可以在接下来的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)
}

6.png

上述代码中,我们定义了一个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])
}

7.png

上述代码中,我们定义了一个方法CardView,传入imageURLname,返回一个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)
    }
}

8.png

整体样式效果

9.png

效果不错!

接下来才是有趣的地方,敲黑板!开始要写逻辑了!

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
        }
    }
}

10.png

上述代码中,我们先声明了一个变量currentTimeName,来作为更新餐段的参数。

然后设置了一个餐段名称的枚举MealTimeName,来表示餐段和对应餐段的名称。

再是定义了一个方法getCurrentTime获得当前时间,只取值到小时,再定义了一个方法updateTime来根据获得到的时间和一些时间段做比较,更新currentTimeName的值。

最后在init调用时调用updateTime更新方法,就得到了当前的餐段currentTimeName的准确值。

更新当前餐段

我们回到ContentView文件中,首先将原先声明的变量DefaultTime加一个存储方式,示例:

@State var DefaultTime:String = "午餐"

然后引入ViewModel的内容,示例:

@ObservedObject private var viewModel = ViewModel()

在主视图展示时,更新当前餐段,示例:

.onAppear(){
    DefaultTime = viewModel.currentTimeName
}

11.png

切换当前餐段

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()
 }

12.png

不错不错!

网络请求数据

让我们回到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"

13.png

首先,我们要声明好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()
}

14.png

上述代码中,我们定义了一个方法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
            }
        }
    }
}

15.png

上述代码中,我们定义了一个方法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
}

16.png

上述代码中,我们定义了一个方法getRandomFood,在方法中,我们从models数组中的所有数据总量生成一个随机数index,然后餐品名称currentName赋值models数组中随机数index下标的foodName,同理餐品图片currentImageURL也是。

这样我们就得到了一个获得该餐段随机餐品的方法。

我们先在viewModel初始化时,调用获得餐品数据和根据餐段筛选商品的方法。示例:

init() {
    updateTime()
    getMenu()
}

17.png

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 = "今天想吃点啥?"

18.png

然后在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)
    }
}

19.png

当然不要忘了,我们还有切换餐段的功能呢,在切换餐段时,我们还需要重新赋值。示例:

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 = "今天想吃点啥?"

20.png

上述代码中,当我们切换餐段的时候,除了餐段时间DefaultTime重新赋值外,我们还调用网络请求重新更新models数组的数据,以及将餐品名称和餐品图片。

点击按钮预览下效果:

21.png

交互动画

动画部分是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()
        }
    }
}

22.png

我们创建了一个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的值,并赋值重新展示推荐结果

项目展示

23.gif

不错不错!

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

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

猜你喜欢

转载自juejin.im/post/7123108996122148877
今日推荐