75142913在线留言
CS193p2021学习笔记第十二课:属性包装器介绍、多视图和编辑视图_IOS开发_网络人

CS193p2021学习笔记第十二课:属性包装器介绍、多视图和编辑视图

Kwok 发表于:2021-09-02 15:02:07 点击:1 评论: 0

上节课主要围绕着错误处理与数据存储的理论和代码演示,都是在Model与ViewModel里完成的,本课将着重使用View对代码演示,在代码演示之前将针对功能对理论知识做一些介绍。

一、属性包装器 Property Wrappers

在前面的课程里我们已使用了很多以@开始的关键字(@State、@Published、@ObservedObject等)这些都是属性包装器,其底层是对结构体的封装(@propertyWrapper struct Published),我们使用的时候就是语法糖(typealias Published = Published)。

1、Published 发布器

@Published private(set) var emojiArt = EmojiArtModel()
/********* 解析如下 ***************/
struct Published{
    var wrappedValue:EmojiArtModel//属性包装器原型
    //当信息流产生了变化,将通过下面这个隐藏属性对其发布
    var projectedValue:Publisher<EmojiArtModel,Never>//通过$emojiArt获取到此变量
    ... //一些其它工作
}

//下划线版本是一个已发布的类型
var _emojiArt:Published = Published(wrappedValue:EmojiArtModel())
//当我们通过语法糖对emojiArt包装以后变成了计算属性
var emojiArt:EmojiArtModel{
    get{ _emojiArt.wrappedValue } //每次get触发Published
    set{ _emojiArt.wrappedValue = newValue }//每当set触发Published
}

通过解析我们看到,使用了属性包装器我们就会获得一个下划线版本(_emojiArt)的属性,很实用的一个功能,这将在下一节课的演示中使用。$emojiArt版本需要根据包装器的类型获取到不同的值。

2、@State 属性状态

State里的wrappedValue可以是任何的数据类型,当我们使用@State var 标记了属性,将会把一些值类型存储在内存的堆里(保存在View之外),并且在发生变化时会让视图被重建。只要视图在屏幕上 内存堆里保存的值就不会消失。

而State的projectedValue是一个绑定(Binding),后面将会专门讲解Binding。假设你通过 @State var 包装属性时没有使用 = 对其赋值的情况下,我们需要使用下划线版本对其初始化。

@State private var foo:Int //没有赋值
init(){
    _foo = .init(initiaValue: 5) //在init里的初始化方式,使用下划线版本(_foo)
}

通常情况下我们不会这样初始化@State,但我们还是需要了解为什么要这样操作,_foo 是来源于结构体State的,并调用State.init初始化方法,此方法针对数据类型不同调用不同的参数。

3、@StateObject 对象状态(7:30)

@StateObject和上面的@State类似将初始化后的数据保存在了内存堆里面,不同点在于@StateObject是用于ObservaBleObjects的。它的形为就像@ObservedObject,在下面的演示中主要用Scenes里的(也是可以存在于Apps和View中的)。在View中使用@StateObject也是一种常见的方式。和@State一样就算是视图重建了也不会重新初始化@StateObject包装过的属性,但如果没有保存在外部直接使用了@ObservaBleObjects var value = MyObject()的方法创建对象的方式则将在视图重建的时候再次初始化掉,回到最初的样子。

所以 @StateObject 后面的属性 直接 使用 “=” ,因为使用数据的真实来源,而 @ObservedObject 不是数据的真实来源,如果使用一个创建于外部的ObservaBleObject属性的引用(我们现在使用的方式,创建在了EmojiArtApp.swift的@main里面)。 

@ObservedObject var foo:SomeObservableObject //在@main struct 项目名App: App { private let docment = SomeObservableObject() }初始化
@StateObject var foo = SomeObservableObject() //直接初始化

@ObservedObject应该是对真实来源的引用(所以在视图里永远不要在其后面使用“=”),这和生命周期有关,因为你可能永远不希望重建视图的时候一遍又一遍的初始化ObservedObject。而@StateObject则是真实的数据来源,就算视图重建了也不会再次初始化ViewModel所以通常我们都使用“=”。在这里http://www.neter8.com/ios/114.html @StateObject 可观察对象节能版 有更多的说明。

在特殊情况下我们也会在VIew使用@ObservedObject的,比如我们需要重建视图时重新去远程URL获取最新的数据,因为一但视图重建、销毁,我们的@ObservedObject初始化的ViewModel也将与视图共存亡。在后面的演示中我们将使用到这个方式舍弃不需要的数据。

4、@Binding 绑定(12:30)

一个常用且非常重要的属性包装器,当我们使用@Binding var foo里,这个foo值一般来源@State、@StateObject或者@ObservedObject。其作用是上级视图传递到子视图的数据可修改版本,所以Binding通过使用到表单视图里。

在这个http://www.neter8.com/ios/109.html实例中就大量的使用了Binding。

Binding还有一个常量绑定即Binding.constant(Value),它绑定到一个常量值上,绑定值时不会改变,通常我们用于预览代码(课程中将演示)。Binding还有一个计算属性Binding(get:,set:)它的初始值设定项的构造函数有get与set,通过get函数出去获取这个Binding的值,而通过set函数的值设置值。因此可以通过提供一个获取数据的函数(get)然后提供可以设置数据的函数(set)来创建自己的任意数据源的绑定。

5、@EnvironmentObject 环境对象(18:45)

一个可以将对象到处应用的属性包装器,与@ObservedObject安全相同,它是对ObservedObject的引用,但EnvironmentObject通过不同的方式传递数据给VIew,不像其它ViewModel参数那样直接通过参数传递,EnvironmentObject的数据传递需要通过调用.environmentObject(theViewModel)的方式,并在接受端使用 @EnvironmentObject var viewModel:ViewModelClass方式。

//通过EnvironmentObject传递数据
let myView = MyView().environmentObject(theViewModel)//向MyView()传递theViewModel
//下面是ObservedObject传递
let myView = MyView(viewModel: theViewModel)//通过viewModel参数传递theViewModel

/**************** 在MyView()里接收数据 *******************/
@EnvironmentObject var viewModel:ViewModelClass //EnvironmentObject接收参数
@ObservedObject var viewModel:ViewModelClass //ObservedObject接收参数

ObservedObject与EnvironmentObject不同之处在于EnvironmentObject对Body中所有视图都是可见的,并可以向body中所有的视图一直向下传递(范围更广),所以我们 叫它 “环境对象” 。如果我们的整个View层次结构中的所有视图都需要使用这个数据,就需要通过EnvironmentObject来传递。这样我们就可以省略到各种视图的参数了(偷懒专用代码)。所以我们可以把EnvironmentObject认为是将数据注入到所有的视图中去。

但EnvironmentObject使用也是有一定限制的,因是传递的特点,每次我都都只能使用一个@EnvironmentObject值。 

6、Environment 系统环境变量(21:42)

这是一个与EnvironmentObject完全不同的东东,看上去虽然 名字很像,但是他们的功能与作用是完全 不一样的。首先属性包装器是可以有更多的变量的,而不仅仅是上面提到的wrappedValue、projectedValue、_valueName和$valueName,属性包装器本质上还是struct所以我们创建的时候是可以有参数的,在我们使用@Environment包装属性时在"()"里指定参数,Environment和其它不同的是需要在参数前面使用\开始(前面使用过的\.selft):

@Environment(\.colorScheme) var colorScheme//是否处理暗黑模式
view.environment(\.colorScheme,.dark)//将视图设置为黑暗模式

系统提供@Environment的可用参数:https://developer.apple.com/documentation/swiftui/environmentvalues ,@Environment本质是我们开发中要控制或者获取到的系统底层的相关参数,是非常实用的一个属性包装器。可以访问系统设置的开放性参数、控件大下,时间、日历、刷新、快捷键等很多操作。 

二、切换表情选择器

在前面的所有课程中我们基本上就只学了在单个视图上使用项目,从本课开始我们将使用多视图跳转,类似于在网页上打开了一个连接一样的操作,但是在swiftUI里概念有一些不同。

1、转移PaletteChooser到新文件(27:02)

上节课我们为palette创建了Model和ViewModel,我们现在是双VM,将会针对PaletteChooser增加很多功能,分离到一个单独的文件方便我们编码。首先新建一个文件PaletteChooser.swift将下面的内容写入,并删除EmojiArtDocumentView.swift原来palette相关的内容。

import SwiftUI

struct PaletteChooser: View {
    let emojiFontSize: CGFloat //上级视图传入以同步字体大小
    var emojiFont:Font{ .system(size:emojiFontSize) }//表情字体属性
    
    @EnvironmentObject var store: PaletteStore//数据源来自EmojiArtApp
    var body: some View {
        let palette = store.palette(at: 0)//通过索引 找到第一个调色板
        HStack{
            Text(palette.name)//调用板的名字
            ScrollingEmojisView(emojis: palette.emojis)//原来的testEmoji不再需要了
                .font(emojiFont)//应用字体属性
        }        
    }
}

//横向滚动视图
struct ScrollingEmojisView:View {
    let emojis:String
    var body: some View{
        ScrollView(.horizontal){
            HStack{
                //removingDuplicateCharacters删除重复的字符,此功能来源于扩展
                /*
                // extension String {
                //     var removingDuplicateCharacters: String {
                //         reduce(into: "") { sofar, element in
                //             if !sofar.contains(element) {
                //                sofar.append(element)
                 //            }
                //         }
                 //    }
               //  }*/
                ForEach(emojis.removingDuplicateCharacters.map { String($0) }, id: .self) { emoji in
                    Text(emoji)
                        .onDrag { NSItemProvider(object: emoji as NSString) }
                }
            }
        }
    }    
}
struct PaletteChooser_Previews: PreviewProvider {
    static var previews: some View {
        PaletteChooser(emojiFontSize: 40)
    }
}

2、更新EmojiArtApp.swift(29:21)

通过今天学习的理论知识,将原来的 ObservedObject 引用源在程序的主入口文件改为下面的代码:

@main
struct EmojiArtApp: App {
    @StateObject private var docment = EmojiArtDocument()//更新为 StateObject
    //通过@StateObject创建docment与paletteStore这二个实例化对象
    @StateObject private var paletteStore = PaletteStore(named: "Default")//当数据发生了变化依赖此对象的View将更新
    
    var body: some Scene {
        WindowGroup {
            //该视图的原来@ObservedObject var document可以改为不再包装属性,哪怕使用let也能正常使用
            EmojiArtDocumentView(document: docment)//深入理解@ObservedObject与@StateObject区别
                .environmentObject(paletteStore)//通过environmentObject在程序入口注入后,所有视图都可用
        }
    }
}

然后在 EmojiArtDocumentView.swift 里将原来的 palette 视图改为:

PaletteChooser(emojiFontSize: defaultEmojiFontSize)//defaultEmojiFontSize同步

当视图创建自己的@ObservedObject实例时,每次丢弃和重新绘制视图时都会重新创建它。相反,@State变量在重新绘制视图时将保留其值。而@StateObject是@ObservedObject和@State的组合-即使在视图被丢弃和重新绘制后ViewModel的实例也会被保留和重用。当然上一课我们在View外面初始化了ViewModel所以并不存在这个问题,这里之所以替换使用为@StateObject完全是为了熟悉和学习,我们今后可以在实战中可根据实际情况和喜好择优使用。

3、切换表情选择器(31:39)

首先我们要定义一个切换表情选择的顺序,通过点击后更新这个值来实际选择不同的表情:

@State private var chosenPaletteIndex = 0//表情选择索引

增加一个点击切换的按钮:

//点击切换表情选择器
var paletteControlButton: some View {
    Button {
        withAnimation {
            //下面是一个经典的算法,请仔细理解,限制在.count范围内
            chosenPaletteIndex = (chosenPaletteIndex + 1) % store.palettes.count
        }
    } label: {
        Image(systemName: "paintpalette")
    }
    .contextMenu { contextMenu } //应用上下文菜单
    .font(emojiFont)//与名称字体一样
}

将视图整理到func body里:

 

//整理body
func body(for palette: Palette) -> some View {
    HStack {
        Text(palette.name)
        ScrollingEmojisView(emojis: palette.emojis)
            .font(emojiFont)
    }
    .id(palette.id)//使ID变更让下面rollTransition过渡生效(关键)
    .transition(rollTransition)//使用自己定义的上翻过渡效果
}
//自己定义的过渡效果(向上翻)
var rollTransition: AnyTransition {
    AnyTransition.asymmetric(
        insertion: .offset(x: 0, y: emojiFontSize),//进入时偏移
        removal: .offset(x: 0, y: -emojiFontSize)//离开时偏移
    )
}

将原来的var body改为下面的代码:

var body: some View {
    HStack {
        paletteControlButton//定义点击后将chosenPaletteIndex++的算法
        body(for: store.palette(at: chosenPaletteIndex))//调用表情切换索引
    }
    .clipped()//向上翻表情的时候溢出隐藏
}

完成这步后还需要实现上下文菜单,在上面的paletteControlButton里已应用了contextMenu,下面我们将需要实现代码。

三、多视图(VIew)跳转

上面完成了点击图标切换各表情选择器并应用过渡动画效果,下面我们将制作一个上下文的菜单,当长按时弹出选项可以对表情选择器管理操作。

1、上下文菜单(38:40)

首先定义2个临时的变量方便我们使用:

@State private var managing = false//管理状态,将传递给sheet打开视图
@State private var paletteToEdit: Palette?//编辑某个表情选择器(1:00:16)

然后通过第九课里的http://www.neter8.com/ios/154.html UtilityViews.swift 扩展文件里查看,为我们增加一个带动画的按钮:

//上下文菜单选项
@ViewBuilder
var contextMenu: some View {
    AnimatedActionButton(title: "编辑", systemImage: "pencil") {
        //编辑状态开启,并传入表情选择器
        paletteToEdit = store.palette(at: chosenPaletteIndex)//使paletteToEdit不等于nil以调用.popover
    }
    AnimatedActionButton(title: "新建", systemImage: "plus") {
        //插入一下新的表情选择器到数组里
        store.insertPalette(named: "新的表情默认名", emojis: "", at: chosenPaletteIndex)
        //然后去编辑这个被新插入的表情选择器
        paletteToEdit = store.palette(at: chosenPaletteIndex)
    }
    AnimatedActionButton(title: "删除", systemImage: "minus.circle") {
        //调用VM里的删除功能
        chosenPaletteIndex = store.removePalette(at: chosenPaletteIndex)
    }
    AnimatedActionButton(title: "管理", systemImage: "slider.vertical.3") {
        managing = true//管理开关状态
    }
    gotoMenu
}

 

2、实现gotoMenu快速跳转到某个表情选择器的功能:

//跳转到某个表情选择器
var gotoMenu: some View {
    Menu { //菜单列表
        ForEach (store.palettes) { palette in //遍历所有的表情选择器
            //通过AnimatedActionButton显示所有的表情选择器
            AnimatedActionButton(title: palette.name) {
                //当用户点击了某个表情选择器后将下面的动作闭包传入并执行
                if let index = store.palettes.index(matching: palette) {
                    //index来源于对Array的扩展功能
                    chosenPaletteIndex = index//找到用户点击的索引并跳转到表情选择器
                }
            }
        }
    } label: {
        Label("转到", systemImage: "text.insert")
    }
}

这个跳转功能以非常巧妙的方式实现了,因为本人长期做web的开发,这对于我来说是一种比较新颖的方式。

CS193p2021学习笔记第十二课属性包装器介绍多视图和编辑视图

四、表情选择器里的表情编辑功能(45:18)

1、新建一个swiftUI文件,命名为PaletteEditor.swift 并写入以下代码:

import SwiftUI

struct PaletteEditor: View {
    @Binding var palette: Palette //绑定要修改的选择器
    
    var body: some View {
        //Form配合Section使用将自动排版
        Form {
            nameSection//名字输入框
            addEmojisSection//增加表情
            removeEmojiSection//移出表情
        }
        .navigationTitle("编辑(palette.name)")//通过NavigationLink跳转过来后的标题
        .frame(minWidth: 300, minHeight: 350)//限制.popover调用时的最小高宽(54:01)
    }
    
    var nameSection: some View {
        Section(header: Text("名字")) {
            TextField("默认名字", text: $palette.name)
        }
    }
    
    @State private var emojisToAdd = ""
    
    var addEmojisSection: some View {
        Section(header: Text("增加表情")) {
            TextField("", text: $emojisToAdd)
            //onChange将检测emojisToAdd是否有更新变化
                .onChange(of: emojisToAdd) { emojis in
                    addEmojis(emojis)//检测到变化调用addEmojis向数组里追加数据
                }
        }
    }
    //追加表情
    func addEmojis(_ emojis: String) {
        withAnimation {
            //点击后向数组追回一个新表情,并过渡重复
            palette.emojis = (emojis + palette.emojis)
                .filter { $0.isEmoji }//扩展isEmoji,检测是否是Emojie
                .removingDuplicateCharacters//删除重复的表情
        }
    }
    //删除点击的表情
    var removeEmojiSection: some View {
        Section(header: Text("删除表情")) {
            //通过扩展里的 removingDuplicateCharacters 删除重复的字符
            let emojis = palette.emojis.removingDuplicateCharacters.map { String($0) }
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 40))]) {
                //在LG上遍历所有的表情
                ForEach(emojis, id: .self) { emoji in
                    Text(emoji)
                        .onTapGesture {
                            //点击被删除的表情调用VM相关功能
                            withAnimation {
                                palette.emojis.removeAll(where: { String($0) == emoji })
                            }
                        }
                }
            }
            .font(.system(size: 40))
        }
    }
}

struct PaletteEditor_Previews: PreviewProvider {
    static var previews: some View {
        //使用constant传入需要绑定的表情选择器(1:05:06)
        PaletteEditor(palette: .constant(PaletteStore(named: "Preview").palette(at: 4)))
            .previewLayout(.fixed(width: 300, height: 350))//固定预览大小
        PaletteEditor(palette: .constant(PaletteStore(named: "Preview").palette(at: 2)))
            .previewLayout(.fixed(width: 300, height: 600))
    }
}

2、将弹出的的表情编辑器应用到视图上(51:46)

我们在上面建立好了编辑器的样式,现在只需要将要编辑的表情选择器传入即可,这里我们先尝试使用.sheet弹出视图,在body下面的HStack上加入以下代码:

//.sheet(isPresented: Bool) 尝试理解isPresented (52:50)
.popover(item: $paletteToEdit) { palette in //使用item参数可以与.sheet自由切换
    //当paletteToEdit不为nil时调用编辑器视图(1:00:46)
    PaletteEditor(palette: $store.palettes[palette])
}
//当关闭popover或者sheet的时候,paletteToEdit将会被重新设置为nil
//使用item调用对数据类型的要求是必须符合可识别协议(Identifiable)

 sheet在iPhone里是全屏的,在iPad里虽然不是全屏,但是占用了大部分的屏幕,因为我们限制了.popover的最小的高宽,所以在iPhone里也是全屏,但屏幕超过限制的大小的尺寸后(横屏状态或者iPad等宽屏下)将以合适的大小显示内容。.popover默认使用最小的高宽来显示内容,所以我们需要配合frme使用。

五、表情选择器管理(1:13:56)

上面我们实现了对表情的编辑功能,现在我们要做的是重复上面的方法对表情管理器进行排序与删除操作。

1、新建一个swiftUI文件做为管理视图,命名为PaletteManager.swift并写入以下内容:

import SwiftUI

struct PaletteManager: View {
    @EnvironmentObject var store: PaletteStore
    
    //检查当前视图处于哪种模式下并绑定到代理变量来控制当前视图(1:33:28)
    @Environment(.presentationMode) var presentationMode
    
    //定义编辑模式,将通过environment环境变量修改当前的模式
    @State private var editMode: EditMode = .inactive
    
    var body: some View {
        //NavigationLink必须要在NavigationView里才能正常使用
        NavigationView {
            List {
                //遍历所有表情选择器
                ForEach(store.palettes) { palette in
                    //通过Link跳转到编辑视图并传参
                    NavigationLink(destination: PaletteEditor(palette: $store.palettes[palette])) {
                        VStack(alignment: .leading) {
                            Text(palette.name)
                            Text(palette.emojis)
                        }
                        //当处于编辑模式时,无效手势(tap)将替换NavigationLink,否则手势为nil则将应用NavigationLink
                        .gesture(editMode == .active ? tap : nil)
                    }
                }
                // 在ForEach里的编辑模式下,用户删除动作时调用VM里的remove方法(1:29:44)
                .onDelete { indexSet in
                    store.palettes.remove(atOffsets: indexSet)
                }
                // 在ForEach里的编辑模式下,用户移动了项目的顺序调用VM里的move方法(1:30:50)
                .onMove { indexSet, newOffset in
                    store.palettes.move(fromOffsets: indexSet, toOffset: newOffset)
                }
            }
            .navigationTitle("管理表情选择器")//标题
            .navigationBarTitleDisplayMode(.inline)//标题显示模式(大小控制)
            //与标题同行的多个其它按钮
            .toolbar {
                ToolbarItem { EditButton() }//增加编辑按钮,让当前List处于编辑模式
                ToolbarItem(placement: .navigationBarLeading) {
                    //增加一个Leading对齐的按钮(1:34:48)
                    if presentationMode.wrappedValue.isPresented, //当此视图状态为显示
                       UIDevice.current.userInterfaceIdiom != .pad {
                        //条件2非iPad
                        Button("关闭") {
                            //调用环境变量绑定值的dismiss方法关闭当前视图
                            presentationMode.wrappedValue.dismiss()
                        }
                    }
                }
            }
            .environment(.editMode, $editMode)//将当前环境是否处于编辑状态注入到绑定变量$editMode
        }
    }
    //啥也不做的空手势,替换其它点击动作(比如:NavigationLink)
    var tap: some Gesture {
        TapGesture().onEnded { }
    }
}

struct PaletteManager_Previews: PreviewProvider {
    static var previews: some View {
        PaletteManager()
            .previewDevice("iPhone 12")//限制预览设备
            .environmentObject(PaletteStore(named: "Preview"))//在预览里传入environmentObject
            .preferredColorScheme(.light)//非暗黑模式
    }
}

2、为body再次增加一个sheet(.popover的下面),之所以增加sheet是为了深入学习通过环境变量来控制sheet的关闭:

//当 managing为true时显示sheet
.sheet(isPresented: $managing) {
    PaletteManager()//显示管理视图
}

3、增加一个警告提示(1:38:15):

我们需要在ViewModel里的BackgroundImageFetchStatus枚举增加一个failed选项:

enum BackgroundImageFetchStatus : Equatable { //增加 Equatable 以修复 Referencing operator function '==' on 的问题
    case idle
    case fetching
    case failed(URL)      
}

然后在fetchBackgroundImageDataIfNecessary里增加一个下载失败后的状态修改:

//修改图片下载失败后的状态
if self?.backgroundImage == nil {
    self?.backgroundImageFetchStatus = .failed(url)//将失败的url存入
}

最后在我们的总视图docmentBody下面增加:

.alert(item: $alertToShow) { alertToShow in
    alertToShow.alert()//闭包里必须返回一个alert,默认情况alertToShow == nil
}
//当backgroundImageFetchStatus状态发生变化时开始检测是否需要触发警告弹出
.onChange(of: document.backgroundImageFetchStatus) { status in
    //当状态为failed时
    switch status {
        case .failed(let url):
            showBackgroundImageFetchFailedAlert(url)//调用此方法显示警告,这里alertToShow被赋值了,所以警告将显示
        default:
            break
    }
}

外部增加一个全局的变量和一个警告内容显示的内容格式: 

@State private var alertToShow: IdentifiableAlert?//定义一个空的IdentifiableAlert来源于扩展的警告弹出格式
 //警告内容格式
private func showBackgroundImageFetchFailedAlert(_ url: URL) {
    //alertToShow的值将不是nil,这里将触发alert()
    alertToShow = IdentifiableAlert(id: "抓取失败: " + url.absoluteString, alert: {
        Alert(
            title: Text("背景图像获取"),
            message: Text("无法加载图像:(url)."),
            dismissButton: .default(Text("OK"))
        )
    })
}

这个警告本来可以很简单的显示的,但在这里绕了几个弯,需要多理解一下,这个到底是以什么样的原理显示出来的。这是一种可复用的通用警告显示方式,在今后的开发中,我们可以借鉴使用。

六、课后总结

今天视频有1小时50分钟长,覆盖了很多的内容,学习了向下传值(@EnvironmentObject)和状态对象(@StateObject),对属性包装器的理论知识做了详细的介绍@State、@Binding、@Environment等。使用了一些小技巧处理问题,使用id区别视图达到转场效果。长按.contextMenu弹出上下文菜单和通过Menu制作弹出菜单,理解.sheet与.popover的区别和使用场景,通过空手势tap限制NavigationLink跳转,还学习了一个复杂且通用的alert警告。

本课的代码量比较多,但理解起来并没有什么难点,比前面几课容易多了,重点是熟悉对Binding的使用,因为不管是.sheet还是.popover,@Environment、表情编辑、alert警告都与Binding息息相关。

 

除非注明,网络人的文章均为原创,转载请以链接形式标明本文地址:http://www.neter8.com/ios/157.html
标签:cs193p多视图编辑模式属性包装器Kwok最后编辑于:2021-09-07 08:07:12
0
感谢打赏!

《CS193p2021学习笔记第十二课:属性包装器介绍、多视图和编辑视图》的网友评论(0)

本站推荐阅读

热门点击文章