swift ui 语法基础

import SwiftUI

/**
    系统版本占有率:https://developer.apple.com/cn/support/app-store/

    持久化数据:@AppStoreage、FileManager、CoreData
*/

@main
struct MyApp: App {
    // @ObservedObject 使用类似 props,一般从外部获取,从内部创建时,每次刷新父级视图会导致数据重建
    // @StateObject 类似 state,一般从当前视图创建,传递给子视图
    @StateObject private var data = CreatureEnvZoo() // 创建可观测对象
    
    init() {
        // 执行初始化操作
    }
    
    var body: some Scene {
        // 启动 app 默认的视图,这里使用 NavigationStack 为顶层视图 
        WindowGroup {
            NavigationStack { 
                StudyView().navigationTitle("我的动物")
            }
            .task {
                data.loadData() // .task 在视图加载前异步执行里面的代码
            }
            .onChange(of: data.creatures) { _ in
                data.saveData() // data.createres 里面的构造题需要使用 Equatable 协议
            }
            .environmentObject(data) // 将 data 作为环境对象,传入顶层视图,让它可以被所有视图使用
        }
    }
}

struct StudyView: View {
    @EnvironmentObject var envData : CreatureEnvZoo // 环境对象
    
    var body: some View {
        // VStack 垂直视图、 HStack 为横向视图、 ZStack 为层叠视图
        // 此外还有长列表用的懒加载的版本 LazyVStack 和 LazyHStack
        VStack {
            // NavigationStack 是导航用的控件
            NavigationStack { 
                NavLinkView() // 链接相关
                ToggleShow() // 布尔值相关
                Spacer() // 用于排版,自适应推开组件
                
                // 基础列表,此外可以以 List(array) 的形式代替 ForEach 渲染
                List { 
                    CreatureAniRows(argData: envData) // 类调用
                    
                    Section("菜单2") {
                        Text("2-1").offset(x: -10, y: -5) // offset 可让元素偏移
                    }
                }.toolbar {
                    ToolbarItem {
                        NavigationLink("添加") {
                            CreatureEditorTest().navigationTitle("添加动物")
                        }
                    }
                }
            }
            .padding(0)
        }
    }
}

// 用于预览
struct StudyView_Previews: PreviewProvider {
    static var previews: some View {
        @StateObject var data = CreatureEnvZoo()
        
        // 将环境对象传递给视图的环境
        return StudyView().environmentObject(data)
    }
}

// 其他文件里的视图类
// 链接相关
struct NavLinkView: View {
    @StateObject private var stateData = CreatureViewZoo() // 当前视图生成的 state object 用 [@StateObject]

    var body: some View {
        // 点击后跳转到目标视图
        NavigationLink { 
            SlidingRectangle().navigationTitle("滑动矩形")
        } label: { 
            // 定义链接样式
            HStack { 
                Text("轻点以导航")
                Spacer()
                Image(systemName: "arrow.forward.circle")
            }.font(.title3) // 放到父级会对子级生效
        }
        // 跳转分栏
        NavigationLink("跳转 ipad 分栏示例") { 
            Mapper(data: stateData).navigationTitle("ipad 分栏")
        }
        // 原生样式
        NavigationLink("跳转到符号集") { 
            LabelGrid().navigationTitle("符号集")
        }
        NavigationLink("布局介绍") { 
            HalfCard()
            HalfCard().rotationEffect(.degrees(180))
        }
    }
}
struct SlidingRectangle: View {
    @State private var width: Double = 0.1
    var show = true
    
    var body: some View {
        // spacing 增加每个元素之间的距离
        VStack(spacing: 20) {
            Slider(value: $width)
            Rectangle().frame(width: width * 1000)
                .overlay(alignment: .topTrailing) {
                    // 通过 overlay 设置遮罩逻辑,下面的条件语句非必需
                    if (show) {
                        Text("遮罩").foregroundStyle(.white)
                    }
                }
        }
        .frame(minWidth: 100).frame(height: 100) // 修改宽高
        .padding(.trailing, 20) // 末尾增加 20 边距
    }
}
// 切换显示
struct ToggleShow: View {
    @State private var show = false // 动态改变的变量前面需要声明[@State]
    private var name = "字符串123" // 静态的变量不需要加[@State]
    
    var body: some View {
        Toggle("切换", isOn: $show) // 控件与变量绑定需要加[$]
        Text("这里是变量:\(name)").opacity(show ? 1 : 0)
    }
}
// 遍历相关
struct Mapper: View {
    @ObservedObject var data: CreatureViewZoo // 引用上层的 state object 用 [@ObservedObject]
    @State private var selectionID: CreatureAni.ID? // 选中的元素的ID
    
    // 组件抽取成函数
    func creatureRowView(ct creature: CreatureAni) -> some View {
        HStack { 
            Text(creature.name).font(.title)
            Spacer()
            Text(creature.emoji)
                .font(.title) // 修改为合适的字体样式
                .frame(minWidth: 125)
        }
    }
    
    var body: some View {
        // NavigationSplitView 用于兼容 ipad,左右分栏的形式
        NavigationSplitView {
            // 点击时,selection 会将传递参数的值修改为该元素的 uuid
            // 如果不用 List 组件,可以在遍历的逻辑里面,将点击元素缓存起来,在 detail 中判断元素是否存在
            List(selection: $selectionID) {
                // 遍历对象
                ForEach(data.creatures) { creature in 
                    self.creatureRowView(ct: creature) // 函数调用,self 可省略
                }
                .onDelete { indexSet in 
                    data.creatures.remove(atOffsets: indexSet)
                }
            }
        } detail: {
            // 寻找 id 相同的元素,并将它声明为变量 creature
            if let creature = data.creatures.first(where: { item in
                item.id == selectionID
            }) {
                CreatureAniDetail(creature: creature).navigationTitle(creature.name)
            }
        }
    }
}

// 将类遵循 ObservableObject 协议,以发布一个可观测对象,让它在视图间共享数据
// 其他文件,元素修改会更新 UI
class CreatureViewZoo : ObservableObject {
    // @Published 标记发布该属性
    @Published var creatures = [
        CreatureAni(name: "Gorilla", emoji: "🦍"),
        CreatureAni(name: "Peacock", emoji: "🦚"),
    ]
}
// 全局环境的可观测对象
class CreatureEnvZoo : ObservableObject {
    @Published var creatures = [
        CreatureAni(name: "霸王龙", emoji: "🦖"),
        CreatureAni(name: "鱿鱼", emoji: "🦑"),
    ]
    // 获取持久化的路径,这里是 zoo.data
    private static func getZooFileURL() throws -> URL {
        FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("zoo.data")
    }
    func loadData() {
        do {
            let fileURL = try CreatureEnvZoo.getZooFileURL()
            let data = try Data(contentsOf: fileURL)
            creatures = try JSONDecoder().decode([CreatureAni].self, from: data) // 解析 json,结构体需要使用 Codable 协议
            print("加载动物数量: \(creatures.count)")
        } catch {
            print("无法加载")
        }
    }
    func saveData() {
        do {
            let fileURL = try CreatureEnvZoo.getZooFileURL()
            let data = try JSONEncoder().encode(creatures) // 转 json
            try data.write(to: fileURL, options: [.atomic, .completeFileProtection]) // 写入文件
            print("保存动物")
        } catch {
            print("无法保存")
        }
    }
}

// 创建对象的结构体,遍历需要 Identifiable、json 需要Codable、.onChange 需要 Equatable
struct CreatureAni : Identifiable, Codable, Equatable {
    var name : String
    var emoji : String
    
    var id = UUID()
    var offset = CGSize.zero
    var rotation : Angle = Angle(degrees: 0)
    
    // json 封装加载时的属性,不提供 CodingKeys 为全部属性
    // 这里是因为 rotation 是 Angle 类型,不支持 json
    enum CodingKeys: String, CodingKey {
        case name
        case emoji
        case id
        case offset
    }
}

// 组件抽取成视图
struct CreatureAniRows: View {
    @ObservedObject var argData: CreatureEnvZoo
    
    var body: some View {
        VStack {
            ForEach(argData.creatures) { creature in 
                let index = argData.indexFor(creature)
                
                CreatureAniRow(creature: creature, index: index)
                // swipeActions 自定义滑动样式
                    .swipeActions {
                        Button(role: .destructive) {
                            argData.creatures.remove(atOffsets: IndexSet(integer: index))
                        } label: {
                            Label("Delete", systemImage: "trash")
                        }
                    }
            }
        }.onTapGesture {
            argData.randomizeOffsets()
        }
    }
}
struct CreatureAniRow: View {
    var creature : CreatureAni
    var index: Int = 0
    
    var body: some View {
        HStack {
            Text(creature.name)
                .font(.title)
            // 动画相关
                .offset(creature.offset)
                .animation(.default.delay(Double(index) / 10), value: creature.offset) // 根据索引延迟每个动画
                .animation(.default, value: creature.offset)
            
            Spacer()
            
            Text(creature.emoji)
                .frame(minWidth: 125)
            // 动画相关
                .offset(creature.offset)
                .rotationEffect(creature.rotation)
                .animation(.spring(response: 0.5, dampingFraction: 0.5), value: creature.rotation) // 弹性动画
                .animation(.default, value: creature.offset)
        }
    }
}

// 对类扩展
extension CreatureEnvZoo {
    func randomizeOffsets() {
        for index in creatures.indices {
            creatures[index].offset = CGSize(width: CGFloat.random(in: -20...20), height: CGFloat.random(in: -20...20))
            creatures[index].rotation = Angle(degrees: Double.random(in: 0...720))
        }
    }
    
    func synchronizeOffsets() {
        let randomOffset = CGSize(width: CGFloat.random(in: -200...200), height: CGFloat.random(in: -200...200))
        for index in creatures.indices {
            creatures[index].offset = randomOffset
        }
    }
    
    func indexFor(_ creature: CreatureAni) ->  Int {
        if let index = creatures.firstIndex(where: { $0.id == creature.id }) {
            return Int(index)
        }
        return 0
    }
}

// 添加列表视图
struct CreatureEditorTest: View {
    @State private var newCreature : CreatureAni = CreatureAni(name: "", emoji: "")
    @EnvironmentObject var data : CreatureEnvZoo 
    @Environment(\.dismiss) var dismiss // 环境变量的关闭
    
    var body: some View {
        // 默认居左,.leading 为居左,.trailing 为居右。 HStack 为 .top、 .bottom
        // 此外可以用 .frame(alignment: .trailing) 指定对齐,Spacer() 也可以
        VStack(alignment: .leading) {
            Form {
                Section("名称") {
                    TextField("名称", text: $newCreature.name)
                }   
                
                Section("表情符号") {
                    TextField("表情符号", text: $newCreature.emoji)
                }
                
                Section("动物预览") {
                    CreatureAniRow(creature: newCreature, index: 0)
                }
            }
        }
        .toolbar { 
            ToolbarItem { 
                Button("添加") { 
                    data.creatures.append(newCreature)
                    dismiss() // 点击按钮后关闭选项卡
                }
            }
        }
    }
}

// 详情
struct CreatureAniDetail: View {
    let creature : CreatureAni
    
    @State private var isScaled = false
    @State private var color = Color.red
    @State private var shadowRadius : CGFloat = 0.5
    @State private var angle = Angle(degrees: 0)
    
    var body: some View {
        VStack {
            Text(creature.emoji)
                .colorMultiply(color)
                .shadow(color: color, radius: shadowRadius * 40)
                .rotation3DEffect(Angle(degrees: 0), axis: (x: 5, y: 2, z: 1))
                .scaleEffect(isScaled ? 1.5 : 1)
                .animation(.spring(response: 0.5, dampingFraction: 0.5, blendDuration: 0.5), value: isScaled)
            
            Button("缩放") { 
                isScaled.toggle()
            }
            
            ColorPicker("选取一种颜色", selection: $color)
            
            HStack { 
                Text("阴影")
                Slider(value: $shadowRadius)
            }
            
        }
        .padding()
    }
}

struct LabelGrid: View {
    @State private var isAddingLabel = false
    @State private var isEditing = false
    @State private var selectedLabel: LabelItem?
    
    // 列的参数
    private static let initialColumns = 3
    @State private var numColumns = initialColumns
    @State private var gridColumns = Array(repeating: GridItem(.flexible()), count: initialColumns)
    
    @State private var labels = [
        LabelItem(name: "tshirt"),
        LabelItem(name: "eyes"),
        LabelItem(name: "eyebrow"),
        LabelItem(name: "moon.stars"),
    ]
    
    // 计算属性,类似 getter
    var columnsText: String {
        numColumns > 1 ? "\(numColumns) Columns" : "1 Column"
    }
    
    var body: some View {
        VStack {
            if isEditing {
                // Stepper 为加减计数器,这里用于修改列的栏数
                Stepper(columnsText, value: $numColumns, in: 1...6, step: 1) { _ in
                    withAnimation { gridColumns = Array(repeating: GridItem(.flexible()), count: numColumns) }
                }
                .padding()
            }
            // 出于性能优化考虑,ScrollView 通常会搭配 LazyVStack 或者 LazyHStack 使用。
            ScrollView {
                // 懒加载的垂直 Grid 视图
                LazyVGrid(columns: gridColumns) {
                    ForEach(labels) { label in
                        NavigationLink {
                            LabelDetail(label: label)
                        } label: {
                            Image(systemName: label.name)
                                .resizable() // 允许图片调整大小
                                .scaledToFit() // 默认为 scaledToFill【缩放以填充,会导致图片拉伸】,scaledToFit 则保持宽高比
                                .symbolRenderingMode(.hierarchical)
                                .foregroundColor(.accentColor)
                                .padding()
                        }
                        .overlay(alignment: .topTrailing) {
                            if isEditing {
                                Button {
                                    remove(label: label)
                                } label: {
                                    Image(systemName: "xmark.square.fill")
                                        .font(.title)
                                        .symbolRenderingMode(.palette)
                                        .foregroundStyle(.white, Color.red)
                                }
                            }
                        }
                        
                    }
                }
            }
        }
        .toolbar {
            ToolbarItem(placement: .navigationBarTrailing) {
                Button {
                    isAddingLabel = true
                } label: {
                    Image(systemName: "plus")
                }
            }
            ToolbarItem(placement: .navigationBarLeading) {
                Button(isEditing ? "完成" : "编辑") {
                    withAnimation {
                        isEditing.toggle()
                    }
                }
            }
            
        }
        // sheet 是模态弹层,isPresented:是否显示,onDismiss:关闭时触发事件
        .sheet(isPresented: $isAddingLabel, onDismiss: addLabel) {
            LabelPicker(label: $selectedLabel) // $selectedLabel 传递给 LabelPicker,进行双向绑定,修改值会影响 $selectedLabel
        }
    }
    
    func addLabel() {
        guard let item = selectedLabel else { return }
        withAnimation {
            labels.insert(item, at: 0)
        }
    }
    
    func remove(label: LabelItem) {
        guard let index = labels.firstIndex(of: label) else { return }
        withAnimation {
            _ = labels.remove(at: index)
        }
    }
}

struct LabelDetail: View {
    var label: LabelItem
    
    var body: some View {
        VStack {
            Text(label.name)
                .font(.largeTitle)
            Image(systemName: label.name)
                .resizable()
                .scaledToFit()
                .symbolRenderingMode(.hierarchical)
                .foregroundColor(.accentColor)
        }
        .padding()
    }
}

// 结构体 LabelItem
struct LabelItem : Identifiable, Equatable {
    var name : String
    var id = UUID()
}

// 选择的界面
struct LabelPicker: View {
    @Environment(\.presentationMode) var presentationMode
    
    // [@Binding]属性包装器允许将一个值绑定到另一个值,这两个值保持同步更新
    // 类似 props,但允许修改,修改会影响原值
    @Binding var label: LabelItem?
    
    let columns = Array(repeating: GridItem(.flexible()), count: 4)
    
    let pickableLabels = [
        LabelItem(name: "waveform.path.ecg.rectangle.fill"),
        LabelItem(name: "gyroscope"),
    ]
    
    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns) {
                ForEach(pickableLabels) { label in
                    Button {
                        self.label = label // 因为 self.label 是双向绑定,此时修改会将传递的 label 的值一起修改
                        presentationMode.wrappedValue.dismiss() // 关闭模态层
                    } label: {
                        Image(systemName: label.name)
                            .resizable()
                            .scaledToFit()
                            .symbolRenderingMode(.hierarchical)
                            .foregroundColor(.accentColor)
                            .ignoresSafeArea(.container, edges: .bottom)
                    }
                    .padding()
                    .buttonStyle(.plain)
                }
            }
        }
    }
}

// 布局介绍
struct HalfCard: View {
    var body: some View {
        VStack {
            Image(systemName: "crown.fill")
                .font(.system(size: 80))
        }
        // .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) // 取消注释才是正确的布局
        .overlay (alignment: .topLeading) {
            VStack {
                Image(systemName: "crown.fill")
                    .font(.body)
                Text("Q")
                    .font(.largeTitle)
                Image(systemName: "heart.fill")
                    .font(.title)
            }
            .padding()
        }
        // 每次使用修饰符【如.border】会生成新的视图,所以下面两个.border的效果不一样,也表示使用顺序很重要
        .border(Color.blue)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .border(Color.green)
    }
}

posted @ 2024-04-03 17:58  _NKi  阅读(24)  评论(0编辑  收藏  举报