SwiftUI小功能模块系列
0001、SwiftUI自定义Tabbar动画效果
0002、SwiftUI自定义3D动画导航抽屉效果
0003、SwiftUI搭建瀑布流-交错网格-效果
0004、SwiftUI-<探探App>喜欢手势卡片
0005、SwiftUI-粘性动画指示器引导页
0006、SwiftUI自定义引导页动画
0007、SwiftUI聚光灯介绍说明
0008、SwiftUI-自定义启动闪屏动画-App启动闪屏曲线路径动画
0009、SwiftUI项目-费用跟踪-记账App项目-第1/3部分
0010、SwiftUI项目-费用跟踪-记账App项目-第2/3部分 -过滤日期详情明细页面以及抽离费用卡片
0011、SwiftUI项目-费用跟踪-记账App项目-第3/3部分 -日期指定选定-新增费用页面
技术:SwiftUI、SwiftUI3.0、费用跟踪、记账、随手记
运行环境:
SwiftUI3.0 + Xcode13.4.1 + MacOS12.5 + iPhone Simulator iPhone 13 Pro Max
使用SwiftUI做一个
记账/费用追踪/随手记
的项目
本次添加日期选定模块 带有⭐️就是本次修改的内容
框选的是本次 比第一个版本新增的文件
思路:
1.创建首页 搭建头部 欢迎部分
2.搭建总费用模块
3. 搭建每笔支出收入的卡片
4. 将内容的第一个字母 作为图标展示
5. 视图模型提供 便捷的方法
6. 模型提供本地测试数据
7. 抽取公共模块 - 费用卡片
8. 当月/日期过滤费用详情页
9. 添加费用页面
ExpenseTracker
颜色
BG#F2F3F6
Gray#A0B1C7
Green#12C7AA
Purple#8744E3
Red#ED4949
Yellow#F6C25A
Gradient1#F2F3F6
Gradient2#D06AF3
Gradient3#F69080
随机图片5张
New Group
命名为 View
New File
选择SwiftUI View
类型 命名为Home
New Group
命名为 Model
New File
选择SwiftUI View
类型 命名为Expense
并改造成模型 继承于Identifiable,Hashable
主要是: 做模型 提供快速创建临时数据
New Group
命名为 ViewModel
New File
选择SwiftUI View
类型 命名为ExpenseViewModel
类型是class
继承于ObservableObject
主要是: 提供View展示的数据 。让view通过视图模型能快速转换得到想展示的值 。比如测试提供的price是int类型。那么视图模型就要提供
int价格
转字符串价格
的方法
New File
选择SwiftUI View
类型 命名为TransactionCardView
主要是用来显示首页列表的费用支付的卡片
New File
选择SwiftUI View
类型 命名为FilteredDetailView
主要展示 筛选日期的费用
New File
选择SwiftUI View
类型 命名为ExpenseCard
- 抽离home的ExpenseCardView
主要
抽离
是因为多个页面使用到总费用卡片
New File
选择SwiftUI View
类型 命名为NewExpense
- 新增费用页面主要是展示主窗口
Home
添加了一个导航NavigationView
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationView{
Home().navigationBarHidden(true)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
- 展示头部的欢迎信息 和 查看当月的消费收入明细
- 费用总模块
- 每笔费用的详细 - 支出、收入、日期、内容
改动 抽取费用总模块
、跳转到当月的消费收入明细
改动 ⭐️ 添加一个费用按钮、以及跳转到下一个页面
import SwiftUI
struct Home: View {
@StateObject var expenseViewModel : ExpenseViewModel = .init()
var body: some View{
ScrollView(.vertical,showsIndicators: false) {
VStack(spacing:12){
HStack(spacing:15){
VStack(alignment:.center,spacing: 4){
Text("Welcome!")
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(.gray)
Text("宇夜iOS")
.font(.title2.bold())
}
.frame(maxWidth:.infinity,alignment: .leading)
NavigationLink {
FilteredDetailView()
.environmentObject(expenseViewModel)
} label: {
Image(systemName: "hexagon.fill")
.foregroundColor(.gray)
.overlay(content: {
Circle()
.stroke(.white,lineWidth: 2)
.padding(7)
})
.frame(width: 40, height: 40)
.background(Color.white,in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.shadow(color: .black.opacity(0.1), radius: 5, x: 5, y: 5)
}
}
ExpenseCard()
.environmentObject(expenseViewModel)
Transactions()
}
.padding()
}
.background{
Color("BG")
.ignoresSafeArea()
}
// 全屏覆盖
.fullScreenCover(isPresented: $expenseViewModel.addNewExpense) {
expenseViewModel.clearData()
} content: {
NewExpense()
.environmentObject(expenseViewModel)
}
.overlay(alignment: .bottomTrailing) {
AddButton()
}
}
// MARK: Transactions View
// 卡片列表
@ViewBuilder
func Transactions() ->some View{
VStack(spacing:15){
Text("Transactions")
.font(.title2.bold())
.opacity(0.7)
.frame(maxWidth:.infinity,alignment: .leading)
.padding(.bottom)
ForEach(expenseViewModel.expenses){ expense in
// MARK: Transaction Card View
// 交易卡视图
TransactionCardView(expense: expense)
.environmentObject(expenseViewModel)
}
}
}
// MARK: Add New Expense Button
// 添加新的费用按钮
@ViewBuilder
func AddButton()->some View{
Button {
expenseViewModel.addNewExpense.toggle()
} label: {
Image(systemName: "plus")
.font(.system(size: 25, weight: .medium))
.foregroundColor(.white)
.frame(width: 55, height: 55)
.background{
Circle()
.fill(
.linearGradient(colors: [
Color("Gradient1"),
Color("Gradient2"),
Color("Gradient3"),
], startPoint: .topLeading, endPoint: .bottomTrailing)
)
}
.shadow(color: .black.opacity(0.1), radius: 5, x: 5, y: 5)
}
.padding()
}
}
struct Home_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
//
// TransactionCardView.swift
// ExpenseTracker (iOS)
//
// Created by lyh on 2022/8/20.
//
import SwiftUI
struct TransactionCardView: View {
var expense: Expense
@EnvironmentObject var expenseViewModel: ExpenseViewModel
var body: some View {
HStack(spacing:12){
// 显示出首字母
if let first = expense.remark.first {
Text(String(first))
.font(.title.bold())
.foregroundColor(.white)
.frame(width:50,height:50)
.background{
Circle()
.fill(Color(expense.color))
}
}
Text(expense.remark)
.fontWeight(.semibold)
.lineLimit(1)
.frame(maxWidth:.infinity,alignment: .leading)
VStack(alignment: .trailing, spacing: 7) {
// 显示的价格
let price = expenseViewModel.convertNumberToPrice(value: expense.type == .expense ? -expense.amount : expense.amount)
Text(price)
.font(.callout)
.opacity(0.7)
.foregroundColor(expense.type == .expense ? Color("Red") : Color("Green"))
Text(expense.date.formatted(date:.numeric,time:.omitted))
.font(.caption)
.opacity(0.5)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 15,style: .continuous)
.fill(.white)
)
}
}
struct TransactionCardView_Previews: PreviewProvider {
static var previews: some View {
Home()
}
}
主要提供模型数据 以及测试数据
//
// Expense.swift
// ExpenseTracker (iOS)
//
// Created by lyh on 2022/8/20.
//
import SwiftUI
// 费用模型和样本数据
struct Expense: Identifiable,Hashable{
var id = UUID().uuidString
var remark: String
var amount: Double
var date: Date
var type: ExpenseType
var color: String
}
enum ExpenseType: String{
case income = "Income"
case expense = "expenses"
case all = "ALL"
}
var sample_expenses: [Expense] = [
Expense(remark: "Magic Keyboard", amount: 99, date: Date(timeIntervalSince1970: 1652987245), type: .expense, color: "Yellow"),
Expense(remark: "Food", amount: 19, date: Date(timeIntervalSince1970: 1652814445), type: .expense, color: "Red"),
Expense(remark: "Magic Trackpad", amount: 99, date: Date(timeIntervalSince1970: 1652382445), type: .expense, color: "Purple"),
Expense(remark: "Uber Cab", amount: 20, date: Date(timeIntervalSince1970: 1652296045), type: .expense, color: "Green"),
Expense(remark: "Amazon Purchase", amount: 299, date: Date(timeIntervalSince1970: 1652209645), type: .expense, color: "Yellow"),
Expense(remark: "Stocks", amount: 2599, date: Date(timeIntervalSince1970: 1652036845), type: .income, color: "Purple"),
Expense(remark: "In App Purchase", amount: 499, date: Date(timeIntervalSince1970: 1651864045), type: .income, color: "Red"),
Expense(remark: "Movie Ticket", amount: 99, date: Date(timeIntervalSince1970: 1651691245), type: .expense, color: "Yellow"),
Expense(remark: "Apple Music", amount: 25, date: Date(timeIntervalSince1970: 1651518445), type: .expense, color: "Green"),
Expense(remark: "Snacks", amount: 49, date: Date(timeIntervalSince1970: 1651432045), type: .expense, color: "Purple"),
]
视图模型 主要提供 视图模型便利的方法
- 比如
正在获取当前月份日期字符串- 将费用换算成货币 - 计算总支付、支付、收入等部分
- 把数字转换成价格
- 添加了
当月费用明细的tab选项
以及将选定日期进行转换成字符串
- 添加 ⭐️ 添加是否显示过滤日期页面
- ⭐️添加新费用的数据
- ⭐️新费用 进行保存 或者 清空数据的情况
//
// ExpenseViewModel.swift
// ExpenseTracker (iOS)
//
// Created by lyh on 2022/8/20.
//
import SwiftUI
class ExpenseViewModel: ObservableObject {
@Published var startDate: Date = Date()
@Published var endDate: Date = Date()
@Published var currentMonthStartDate: Date = Date()
// 费用/收入表
@Published var tabName : ExpenseType = .expense
// ⭐️MARK: Filter View
// 过滤视图
@Published var showFilterView : Bool = false
// ⭐️新的费用属性
@Published var addNewExpense : Bool = false
@Published var amount : String = ""
@Published var type : ExpenseType = .all
@Published var date : Date = Date()
@Published var remark : String = ""
init(){
// 读取当前月份的起始日期
let calendar = Calendar.current
let components = calendar.dateComponents([.year,.month], from: Date())
startDate = calendar.date(from: components)!
currentMonthStartDate = calendar.date(from: components)!
}
// //你可以自定义更多的数据(Core Data)
@Published var expenses : [Expense] = sample_expenses
// 正在获取当前月份日期字符串
func currentMonthDateString()->String{
return currentMonthStartDate.formatted(date: .abbreviated, time: .omitted) + " - " + Date().formatted(date: .abbreviated, time: .omitted)
}
// 将费用换算成货币
func convertExpensesToCurrency(expenses: [Expense],type:ExpenseType = .all)-> String {
var value : Double = 0
value = expenses.reduce(0, { partialResult, expense in
return partialResult + (type == .all ? (expense.type == .income ? expense.amount : -expense.amount) : (expense.type == type ? expense.amount : 0))
})
let formatter = NumberFormatter()
formatter.numberStyle = .currency
return formatter.string(from: .init(value: value)) ?? "$0.00"
}
// 把数字转换成价格
func convertNumberToPrice(value: Double)->String{
let formatter = NumberFormatter()
formatter.numberStyle = .currency
return formatter.string(from: .init(value: value)) ?? "$0.00"
}
// 将选定日期转换为字符串
func convertDateToString() -> String{
return startDate.formatted(date: .abbreviated, time: .omitted) + " - " +
endDate.formatted(date: .abbreviated, time: .omitted)
}
// ⭐️清除所有数据
func clearData(){
date = Date()
type = .all
remark = ""
amount = ""
}
// ⭐️保存数据
func saveData(env: EnvironmentValues){
// 在这里做动作
print("Save")
//这是为UI演示
//替换为Core Data Actions
let amountInDouble = (amount as NSString).doubleValue
let colors = ["Yellow","Red","Purple","Green"]
let expense = Expense(remark: remark, amount: amountInDouble, date: date, type: type, color: colors.randomElement() ?? "Yellow")
withAnimation{expenses.append(expense)}
expenses = expenses.sorted(by: { first, scnd in
return scnd.date < first.date
})
env.dismiss()
}
}
过滤日期详情页
主要展示 当个月的1号 到至今的一个收入与消费的详情页面
注意没有实现
- 用户可以根据日期 展示指定日期的总费用 - 只实现效果
//
// FilteredDetailView.swift
// ExpenseTracker (iOS)
//
// Created by lyh on 2022/8/21.
//
import SwiftUI
struct FilteredDetailView: View {
@EnvironmentObject var expenseViewModel: ExpenseViewModel
// 环境值
@Environment(\.self) var env
@Namespace var animation
var body: some View {
ScrollView(.vertical,showsIndicators: false){
VStack(spacing:15){
HStack(spacing:15){
// 返回按钮
Button {
env.dismiss()
} label: {
Image(systemName: "arrow.backward.circle.fill")
.foregroundColor(.gray)
.frame(width: 40, height: 40)
.background(Color.white,in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.shadow(color: .black.opacity(0.1), radius: 5, x: 5, y: 5)
}
Text("Transactions")
.font(.title2.bold())
.frame(maxWidth:.infinity,alignment: .leading)
Button {
} label: {
Image(systemName: "slider.horizontal.3")
.foregroundColor(.gray)
.frame(width: 40, height: 40)
.background(Color.white,in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.shadow(color: .black.opacity(0.1), radius: 5, x: 5, y: 5)
}
}
// 当前选择日期的费用卡视图
ExpenseCard()
.environmentObject(expenseViewModel)
CustomSegmentedControl()
.padding(.top)
// 当前过滤日期与金额
VStack(spacing:15) {
Text(expenseViewModel.convertDateToString())
.opacity(0.7)
Text(expenseViewModel.convertExpensesToCurrency(expenses: expenseViewModel.expenses, type: expenseViewModel.tabName))
.font(.title.bold())
.opacity(0.9)
.animation(.none,value:expenseViewModel.tabName)
}
.padding()
.frame(maxWidth:.infinity)
.background{
RoundedRectangle(cornerRadius: 15,style: .continuous)
.fill(.white)
}
.padding(.vertical,20)
ForEach(expenseViewModel.expenses.filter{
return $0.type == expenseViewModel.tabName
}){ expense in
TransactionCardView(expense: expense)
.environmentObject(expenseViewModel)
}
}
.padding()
}
.navigationBarHidden(true)
.background{
Color("BG")
.ignoresSafeArea()
}
}
// 自定义分段控制
@ViewBuilder
func CustomSegmentedControl()-> some View{
HStack(spacing:0){
ForEach([ExpenseType.income,ExpenseType.expense],id:\.rawValue) {tab in
Text(tab.rawValue.capitalized)
.fontWeight(.semibold)
.foregroundColor(expenseViewModel.tabName == tab ? .white : .black)
.opacity(expenseViewModel.tabName == tab ? 1 : 0.7)
.padding(.vertical,12)
.frame(maxWidth:.infinity)
.background{
// 与匹配的几何
if expenseViewModel.tabName == tab {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(
LinearGradient(colors: [
Color("Gradient1"),
Color("Gradient2"),
Color("Gradient3"),
], startPoint: .topLeading, endPoint: .bottomTrailing)
)
.matchedGeometryEffect(id: "TAB", in: animation)
}
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation {expenseViewModel.tabName = tab}
}
}
}
.padding(5)
.background{
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(.white)
}
}
}
struct FilteredDetailView_Previews: PreviewProvider {
static var previews: some View {
FilteredDetailView().environmentObject(ExpenseViewModel())
}
}
费用卡片
- 从首页抽离为什么要抽离 因为页面公用了
- 首页用到
- 过滤日期页面用到
- ⭐️ 添加了一个 是否为过滤视图展示
因为过滤日期页面直接进去 默认是显示当前月展示操作
如果过滤日期页通过选择日期 就需要展示 当前选择日期的操作
import SwiftUI
struct ExpenseCard: View {
@EnvironmentObject var expenseViewModel : ExpenseViewModel
var isFilter : Bool = false
var body: some View {
GeometryReader{proxy in
RoundedRectangle(cornerRadius: 20,style: .continuous)
.fill(.linearGradient(colors: [
Color("Gradient1"),
Color("Gradient2"),
Color("Gradient3")],startPoint: .topLeading, endPoint: .bottomTrailing))
VStack(spacing: 15){
VStack(spacing:15){
// MARK : Currently Going Month Date String
// 当前月日期字符串
// 如果是过滤日期 就显示选择的日期 否则显示 当前月的日期
Text(isFilter ?expenseViewModel.convertDateToString() : expenseViewModel.currentMonthDateString())
.font(.callout)
.fontWeight(.semibold)
// MARK: Current Month Expenses Price
// 本月费用价格
Text(expenseViewModel.convertExpensesToCurrency(expenses: expenseViewModel.expenses))
.font(.system(size: 35, weight: .bold))
.lineLimit(1)
.padding(.bottom,5)
}
.offset(y:-10)
HStack(spacing: 15){
Image(systemName: "arrow.down")
.font(.caption.bold())
.foregroundColor(Color("Green"))
.frame(width:30,height: 30)
.background(.white.opacity(0.7),in:Circle())
VStack(alignment: .leading, spacing: 4) {
Text("Income")
.font(.caption)
.opacity(0.7)
Text(expenseViewModel.convertExpensesToCurrency(expenses: expenseViewModel.expenses, type: .income))
.font(.callout)
.fontWeight(.semibold)
.lineLimit(1)
.fixedSize()
}
.frame(maxWidth:.infinity,alignment: .leading)
Image(systemName: "arrow.up")
.font(.caption.bold())
.foregroundColor(Color("Red"))
.frame(width:30,height: 30)
.background(.white.opacity(0.7),in:Circle())
VStack(alignment: .leading, spacing: 4) {
Text("Expenses")
.font(.caption)
.opacity(0.7)
Text(expenseViewModel.convertExpensesToCurrency(expenses: expenseViewModel.expenses, type: .expense))
.font(.callout)
.fontWeight(.semibold)
.lineLimit(1)
.fixedSize()
}
}
.padding(.horizontal)
.padding(.trailing)
.offset(y:15)
}
.foregroundColor(.white)
.frame(maxWidth:.infinity,maxHeight: .infinity,alignment: .center)
}
.frame(height:220)
.padding(.top)
}
}
MARK: Expense Gradient CardView
费用卡片
//@ViewBuilder
//func ExpenseCardView() -> some View{
//
//}
新增费用页
- 搭建关闭按钮
- 输入模块
- 选择日期
- 复选框 选择支出 还是 收入
- 保存操作 - 需要判断用户是否填写收入还是支出、和金额
//
// NewExpense.swift
// ExpenseTracker (iOS)
//
// Created by lyh on 2022/8/22.
//
import SwiftUI
struct NewExpense: View {
@EnvironmentObject var expenseViewModel : ExpenseViewModel
// MARK: Enviroment Values
@Environment(\.self) var env
var body: some View {
VStack{
VStack(spacing:15){
Text("Add Expenses")
.font(.title2)
.fontWeight(.semibold)
.opacity(0.5)
//自定义TextField
//对于货币符号
if let symbol = expenseViewModel.convertNumberToPrice(value: 0).first{
TextField("0", text: $expenseViewModel.amount)
.font(.system(size: 35))
.foregroundColor(Color("Gradient2"))
.multilineTextAlignment(.center)
.keyboardType(.numberPad)
.background{
Text(expenseViewModel.amount == "" ? "0" : expenseViewModel.amount)
.font(.system(size: 35))
.opacity(0)
.overlay(alignment: .leading) {
Text(String(symbol))
.opacity(0.5)
.offset(x: -15, y: 5)
}
}
.padding(.vertical,10)
.frame(maxWidth: .infinity)
.background{
Capsule()
.fill(.white)
}
.padding(.horizontal,20)
.padding(.top)
}
// 自定义标签
Label{
TextField("Remark",text:$expenseViewModel.remark)
.padding(.leading,10)
} icon:{
Image(systemName:"list.bullet.rectangle.portrait.fill")
.font(.title3)
.foregroundColor(Color("Gray"))
}
.padding(.vertical,20)
.padding(.horizontal,15)
.background{
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.white)
}
.padding(.top,25)
Label{
// 复选框
CustomCheckBoxes()
} icon:{
Image(systemName:"arrow.up.arrow.down")
.font(.title3)
.foregroundColor(Color("Gray"))
}
.padding(.vertical,20)
.padding(.horizontal,15)
.background{
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.white)
}
.padding(.top,25)
Label{
DatePicker.init("",
selection: $expenseViewModel.date,
in: Date.distantPast...Date(),
displayedComponents:[.date])
.datePickerStyle(.compact)
.labelsHidden()
.frame(maxWidth:.infinity,alignment:.leading)
.padding(.leading,10)
} icon:{
Image(systemName:"calendar")
.font(.title3)
.foregroundColor(Color("Gray"))
}
.padding(.vertical,20)
.padding(.horizontal,15)
.background{
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.white)
}
.padding(.top,55)
}
.frame(maxHeight:.infinity,alignment:.center)
// 保存按钮
Button(action: {expenseViewModel.saveData(env: env)}) {
Text("Save")
.font(.title3)
.fontWeight(.semibold)
.padding(.vertical,15)
.frame(maxWidth: .infinity)
.background{
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(
LinearGradient(colors: [
Color("Gradient1"),
Color("Gradient2"),
Color("Gradient3"),
], startPoint: .topLeading, endPoint: .bottomTrailing)
)
}
.foregroundColor(.white)
.padding(.bottom,10)
}
.disabled(expenseViewModel.remark == "" || expenseViewModel.type == .all || expenseViewModel.amount == "")
.opacity(expenseViewModel.remark == "" || expenseViewModel.type == .all || expenseViewModel.amount == "" ? 0.6 : 1)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background{
Color("BG")
.ignoresSafeArea()
}
.overlay(alignment: .topTrailing) {
// 关闭按钮
Button {
env.dismiss()
} label: {
Image(systemName: "xmark")
.font(.title2)
.foregroundColor(.black)
}
.padding()
}
}
// 复选框
func CustomCheckBoxes()->some View{
HStack(spacing:10){
ForEach([ExpenseType.income,ExpenseType.expense],id: \.self){ type in
ZStack{
RoundedRectangle(cornerRadius: 2)
.stroke(.black,lineWidth: 2)
.opacity(0.5)
.frame(width: 20, height: 20)
if expenseViewModel.type == type {
Image(systemName: "checkmark")
.font(.caption.bold())
.foregroundColor(Color("Green"))
}
}
.contentShape(Rectangle())
.onTapGesture {
expenseViewModel.type = type
}
Text(type.rawValue.capitalized)
.font(.callout)
.fontWeight(.semibold)
.opacity(0.7)
.padding(.trailing,10)
}
}
.frame(maxWidth:.infinity,alignment: .leading)
.padding(.leading,10)
}
}
struct NewExpense_Previews: PreviewProvider {
static var previews: some View {
NewExpense()
.environmentObject(ExpenseViewModel())
}
}