SwiftUI一日速成
Posted 颐和园
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了SwiftUI一日速成相关的知识,希望对你有一定的参考价值。
Embed 功能
SwiftUI 可以将一个或多个 View 外部再包裹一层 View 。在某个 SwiftUI View 上右键,Show Code Actions (快捷键 command+shift+左键),选择 Embed in HStack/VStack/ZStack/Group/Button/List(Xcode 13)。
VStack
背景色
修改背景色用 background 修饰符:
VStack
...
.background(Color(red: 0xf2/255.0, green: 0xf2/255.0, blue: 0xf2/255.0))
Rectangle
或者用一个嵌入一个 Rectangle:
VStack
Rectangle()
.fill(Color.green)
.frame(width: 200, height: 200)
.padding()
.border(Color.red)
圆角
此外,记得圆角一定要放在背景色之后设置,否则无效:
.background(Color(red: 0xf2/255.0, green: 0xf2/255.0, blue: 0xf2/255.0))
.cornerRadius(10)
描绘边框
通过 overlay 修饰符,可以在 View 上描绘(覆盖)一层几何图形,比如圆角矩形(RoundedRectangle)、矩形(Rectangle)、圆形(Circle)等。
Image(...)
.overlay(RoundedRectangle(cornerSize: CGSize(width: 20,height: 20)).stroke(Color.gray,lineWidth: 4))
填充安全区
如果要让举行填充整个屏幕空间(包括安全区),可以:
Rectangle()
.fill(Color.black.opacity(0.6))
.edgesIgnoringSafeArea(.all)
分割线
Divider()
.background(Color.purple) // 默认颜色为灰色
.scaleEffect(CGSize(width: 1, height: 10)) // 高度放大10倍
.padding(Edge.Set.init(arrayLiteral: .top, .bottom), 20) // 上下内边距设置20,
不能超过 10 个子 View 的限制
其实所有的容器(Container)都有这个限制,包括:
- VStack
- HStack
- ZStack
- Group
- List
解决的办法就是在容器中嵌套容器,当然每一层仍然不能超过10个,比如在 VStack 中嵌套 10 个 Group,然后在每个 Group 中又嵌套 10 个 View。
布局
padding
SwiftUI 的布局基于 padding。左留白:
.padding(.leading, 10)
两边留白:
.padding(.horizontal) // 或者 .padding([.leading, .bottom]),或者 .padding(.vertical,11)
四边留白:
.padding(10.0) // 或者 .padding(),默认好像是 8?
offset 和 padding
设置图片的坐标向上偏移:
Image(...).offset(x: 0, y: -130).padding(.bottom, -130)
offset 偏移的是图片的内容,并不会移动图片的 frame。这会导致虽然图片显示内容上移后,下方留下 130 像素的空白区域。要消除这个区域,我们使用了一个 padding 修饰符,让下边距扣减 130。
所以在实际开发中,offset 和 padding 需要组合使用。
Image
常用 Modifier:
- cornerRadius(3.0) 圆角
- resizable() 允许修改原图大小,这个修饰符必须放在其它修改 frame(包括 frame 和纵横比)之前,否则其它修饰符无效
- apsectRatio(contentMode: ) 修改 content mode。scaleToFit/scaleToFill 是这个方法的便利方法
- frame(minWitdh: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) 宽高自适应 0~∞,等同于占满全屏
- frame(width:40, height: 40) 设置固定大小
- clipped() 修改 frame 之后还必须调用这个修饰符,否则图片的内容又可能超出 frame
- tapAction self.zoomed.toggle() 触摸事件处理
- clipShape(Circle()) 裁剪成圆形
- overlay(Circle().stroke(Color.white,lineWidth: 4)) 覆盖(描绘)白边
- shadow(radius: 10) 阴影
- listRowInsets() 如果 Image 位于 List 中,Image 四边会自动带有 padding,如果需要去掉这个 padding,需要应用 listRowInsets(EdgeInsets())
字体图标
Image 类可以使用系统内置的系统图标,这些系统图标可以通过一个 SF Symbols 的 App (MacOS) 来查找。由于是字体图标,你可以修改字体的大小和颜色,比如:
Image(systemName: "person.crop.circle").imageScale(.large).color(.gray)
NavigationView 和 navigationBarTitle
相当于 UIKit 中的 Navigation View Controller。用于将某个 View 包裹在一个 Navigation View Controller并提供了 Navigation Bar。
NavigationView()
List() ... .navigationBarTitle(Text("一个 Title"), displayMode: .inline)
上面的代码在 List 的外面套了一个 NavigationViewController,同时定义了一个导航栏,使用传统样式的标题(文字居中)。
**注意,navigation bar 并不是在 NavigationView 上进行设置,而是在它所包裹的 View 上设置。**这类似于在 ViewController 上设置 navigation Bar 而不是在 NavigationViewController 上设置。从这里也可以看出,SwiftUI View 就相当于 UIKit 的 UIView。
在 iPad 上的 NavigationView
这是 NavigationView 的一个坑,默认情况下 NavigationView 在 iPad 上是以 split view 的方式显示的,因此它和 iPhone 上看起来不一样!
要改变这点,需要手动设置 navigationViewStyle :
NavigationView
...
.navigationViewStyle(StackNavigationViewStyle())
NavigationLink 导航组件
跳转按钮,允许跳转到另外一个页面:
NavigationLink(destination:Text("下一页"))
Image(...)
NavigationLink() 的第一个参数 destination 是一个SwiftUI View,指向要跳转到的那个页面,第二个参数 label 是一个 block,这个 block 返回的也是一个SwiftUI View,用来定义按钮的外观,可以是一个 Text ,也可以是一个 Image。
NavigationLink 的另一种用途是使用它来作为一种导航,就像 UIKit 中的 Segue,它不一定有 UI(你看不见它),但是你可以通过它,让其它 UI 控件也能实现页面的导航。
首先你需要一个 @State 属性,来控制某个页面的显示与隐藏:
@State var pushActive = false
然后你需要一个“不可见”的 NavigationLink 来充当这个导航 Segue:
NavigationLink(
destination: MechanicalCheckView(viewModel: MechanicalCheckViewModel()),
isActive: self.$pushActive
)
EmptyView()
.hidden()
这里,除了 destintaion 和 label 参数外,还多了一个 isActive 参数,用来绑定 pushActive 属性(使用 $ 关键字)。当 pushActive 被改变时,UI 会显示完全不同的结果(绑定了刷新动作)。当 pushActive 默认值为 false 时,此导航不会发生,页面显示之前的页面。一旦将 pushActive 修改为 true,NavigationLink 的导航会生效,也就是显示第二个页面。同时,这个 NavigationLink 不需要显示,因此它的 label 是一个空白视图(EmptyView 不会占用屏幕空间)。同时用 hidden() 进行了隐藏。
什么时候发生 push 导航?那是由另一个控件(比如普通 button)来触发的,触发导航的方式很简单,修改 pushActive = true 即可:
Button(action:
pushActive = true
, label:
...
)
这就是 NavigatoinLink 的真实用途,它并不仅仅是用于在页面上显示一个button,而是用来代替 UIKit 的 segue 组件。
dismiss 返回
struct DestinationView: View
// 声明属性presentationMode
@Environment(\\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View
Text("Destination View")
.onTapGesture
// 返回
self.presentationMode.wrappedValue.dismiss()
@Environment将全局变量绑定到本地属性。
ScrollView
ScrollView 控制水平布局还是垂直布局是方式是在内部使用 HStack 或 VStack:
ScrollView(.horizontal, showsIndicators: false)
HStack(spacing: 15)
ForEach(...)
...
这里 .horizontal 不是控制水平布局的,仅仅是指定允许手指滑动的方向,showsIndicators 指定是否显示滚动条。
自定义导航栏/工具栏
自定义导航栏其实就是自定义工具栏,因为你可以先隐藏导航栏,然后在设置一个工具栏代替它:
content
.navigationBarBackButtonHidden(true)
.navigationBarTitleDisplayMode(.inline)
.toolbar(content: buildToolbar)
接下来看具体怎么实现。
实现 ViewModifier
新建 swift 文件 Toolbar.swift。首先继承 ViewModifier:
struct FvtToolbar: ViewModifier
var title: String
var rightTitle: String
var rightAction: ()->()
init(_ title:String, _ rightTitle:String, _ rightAction:@escaping ()->())
self.title = title
self.rightTitle = rightTitle
self.rightAction = rightAction
3 个属性分别是:
- title 工具栏中间的标题
- rightTitle 工具栏右按钮的标题
- rightAction 工具栏右按钮的点击事件处理回调
init 方法负责初始化它们。
作为一个 ViewModifier,最重要的方法就是 body 方法:
func body(content: Content) -> some View
content
.navigationBarBackButtonHidden(true)
.navigationBarTitleDisplayMode(.inline)
.toolbar(content: buildToolbar)
content 参数实际上就是工具栏所在的 View ,将由 modifier 方法传入。我们先隐藏了 back button 和导航栏原来的标题,然后用 toolbar(content:) 方法设置一个工具栏。其中 buildToolbar 是一个特殊 ToolbarContentBuilder 结构体的实例。我们无需真的去定义一个 ToolbarContentBuilder,而是只需定义一个能够返回 ToolbarContent 的方法即可:
@ToolbarContentBuilder
func buildToolbar() -> some ToolbarContent
ToolbarItem(placement: .principal)
Text(title)
.font(.system(size: 17, weight: .bold, design: .default))
ToolbarItem(placement: .navigationBarTrailing)
Button(action:
rightAction()
)
Text(rightTitle)
.font(.system(size: 17, weight: .regular, design: .default))
@ToolbarContentBuilder 注解将方法包装成 toolbar 方法要求传入的 ToolbarContentBuilder 结构。而这个方法对方法名和参数都没有要求,但对返回值,要求是 ToolbarContent,即一个 ToolbarItem 的集合,同时这个集合可以通过所谓的“多表达式闭包“得到。所谓的”多表达式闭包“,如同 body 方法,一个闭包中会包含多个 swift 表达式,swift 将这些表达式包装在一个集合中作为闭包的返回值。
扩展装饰器方法
为了便于使用,我们可以为 View 扩展出一个装饰器方法:
extension View
func fvtToolbar(_ title:String, _ rightTitle:String, _ rightAction:@escaping ()->()) -> some View
return modifier(FvtToolbar(title, rightTitle, rightAction))
fvtToolbar 方法接受 3 个参数,分别是 title、rightTitle 和 rightAction。返回一个 View。方法的返回值必须用 modifier 方法包裹,以支持 View 的链式调用。此外,modifier 会将自己的 content 传递给所包裹的对象。被包裹的对象必须是一个 ViewModifier
,比如FvtToolbar,它就实现了 ViewModifier (一个协议)。这样 ,modifier 会调用它的 body 方法,从而实现对目标视图的修改(比如将导航栏用我们提供的 Toolbar 代替)。
调用扩展的装饰器
在视图的 body 属性的最外层根视图使用该装饰器:
.fvtToolbar("MECHANICAL_CHECK_TITLE".localize,
"NAVIGATION_BUTTON_CANCEL".localize)
...
这样,视图会呈现一个由文字标题栏和取消按钮构成的 toolbar。
EditButton
SwfitUI 内置了编辑按钮:
EditButton().padding()
SegmentedControl
SegmentedControl 是一个容器,里面包裹了多个 Text:
SegmentedControl(selection: $profile.prefersSeason)
ForEach(User.Season.allCases.identified(by: \\.self))
season in
Text(season.rawValue).tag(season)
$profile.prefersSeason 进行了双向绑定,其中 profile 是一个视图状态(@State)。ForEach 循环总是需要Sequence对象有一个唯一 id。
Identified(by:)
SwiftUI 的 List 在循环一个数组时,要求数组元素要么实现了 Identifiable 协议(其实就一个 id 属性),要么调用 identified(by:) 方法:
List(landmarkds.indetified(by: \\.id))()
\\.id
或\\.self
是swift 的 keypath 语法。
绑定视图的刷新 - @State
视图状态绑定了刷新操作,当视图中的 @State 属性被修改,自动触发页面的刷新(调用 body 块)。
@State var zoomed = false
@State 还带来了另外一个效果,就是改变结构体成员的可变性。结构体跟类不同,普通成员函数中,无法对自身结构体的属性进行赋值操作,比如如下代码:
func present(isPresented: Bool) -> some View
isShow = isPresented
return self
编译器报错:Cannot assign to property: ‘self’ is immutable。以往的解决办法是在函数前用 mutating 修饰:
mutating func present(isPresented: Bool) -> some View
...
但现在不用了,直接在 isShow 前面加一个 @State:
@State var isShow: Bool = false
@Published 属性包裹器
类似于 @State,@Published 允许你将被修饰的属性绑定指定视图的刷新操作。不同的是 @State 的绑定动作是自动的,默认就是绑定到 @State 属性所在的 View,但 @Published 需要你手动绑定到指定的 View。换句话说,@Published 属性可以用于任何 View ,但 @State 只能用于当前 View。
如果要让某个类中的属性能够绑定视图刷新操作,这个类必须实现 ObservableObject 协议:
class Bag: ObservableObject
var items = [String]()
如果你想让 items 属性能够绑定视图,则用 @Published 修饰它:
@Published var items = [String]()
这个语法糖会自动添加 willSet 属性监听器方法。
这样,Bag 就变成了一个 ObservableObject 对象。你可以在任意 View 中绑定它。只需在这个 View 中使用 @ObservedObject 声明一个 Bag 属性:
struct ContentView: View
@ObservedObject var bag = Bag()
var body: some View
...
注意 @ObservedObject 关键字的使用,它将 bag 对象包装成外部可访问和修改的。这点和 @State 不同,@State 的对象一般是 private 的。这样,当你修改 bag 中的 items 值时,会触发 ContentView 更新。
View 的生命周期
类似于 UIViewController 的 viewDidAppear/viewDidDisappear, SwiftUI View 也有对应的生命周期方法:
.onAppear
// viewModel.startSNRCheck()
viewModel.showRemovingStep = true
.onDisappear
// viewModel.startSNRCheck()
viewModel.showRemovingStep = true
还有一个特殊的 onReceive 方法:
.onReceive(viewModel.$isTestSucceeded) testResult in
isTestSucceeded = testResult
这个方法主动监听某个 Observable 对象的 published 属性,如果值发生变化,调用指定的块,块参数为新值。
SwiftUI 的动画
withAnimation
当某个 @State 属性被改变时,页面改变,同时让这种改变以动画方式进行,那么可以将该属性的修改代码包裹在 withAnimation 块中:
withAnimation(.basic(duration:1))
self.zoomed.toggle()
Preview 可能看不到动画效果,需要在模拟器里面执行。withAnimation 的动画会影响整个 View,因为 @State 的刷新会导致整个 body 刷新。
注意,调试动画时不要使用 Canvas(可能看不出效果),最好在模拟器上调试。
transition
此外还有另一种 SwiftUI 动画 Transition ,则是单独对某个 View 执行动画:
Text("...").transition(.move(edge.trailing))
.move 指定该动画是平移,并且视图将从(from)屏幕的右侧(trailing)滑入。注意 .move() 的参数实际是 from,to 就是当前位置。
swiftUI 中的动画是可取消动画,不需要等上一个动画彻底完成即可执行下一个动画。
变形恢复
可以让 View 恢复到变形(缩放、平移、旋转)之前 :
.transition(.identity)
旋转和缩放
旋转使用修饰符 rotationEffect,缩放使用修饰符 scaleEffect:
Image(...)
.rotationEffect(.degrees(showDetaiol ? 90 : ))
.scaleEffect(showDetail ? 1.5 : 1)
如果在切换 showDetail 状态时使用了 withAnimation 块,则旋转和缩放将以动画方式执行:
withAnimation(.spring())
self.showDetail.toggle()
animation
transition 是几何变形,但要让这个变形能够以动画方式进行,需要用到隐式动画 animation。在 SwiftUI 中,withAnimation 叫做显式动画,animation 叫做隐式动画。animation 修饰符和 transition 修饰符一样,也是作用于特定的 View(而不是 body 中的所有 view),它支持多种动画特效,比如弹簧动画:
.opacity(isShow ? 1 : 0)
.animation(.spring())
当然也可以匀速进行并指定动画时长 duration:
.animation(.linear(duration: 2))
GeometryReader
此外需要注意,隐式动画会影响到所有动画属性,包括位置,也就是说,隐式动画一创建时会默认位置从左上角开始(0,0)。因此为了让它不影响到我们的位置,我们需要用到 GeometryReader:
var body: some View
GeometryReader geometry in
VStack
.opacity(isShow ? 1 : 0)
.animation(.spring())
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .center)
这样它在动画开始时将 frame 设置到布局时的原始位置(宽高、中心都等于 geometry)而不是左上角(0,0)。
animation 的位置
animation 修饰符的位置非常重要,它让之前的动画特效生效。如果你把它放到一个 scaleEffect 之前执行,那么 animation 没有任何意义:
.animation(.easeIn(duration: 2))
.scaleEffect( isPresented ? 1: 0)
因为在 animation 之前没有任何特效设置语句。你必须把 scaleEffect 放到前面执行:
.scaleEffect( isPresented ? 1: 0)
.animation(.easeIn(duration: 2))
animatino 取消
animation 还有一种特殊用法 animation(nil) ,表示取消之前的动画,例如:
Image(...)
.rotationEffect(.degrees(showDetaiol ? 90 : ))
.animation(nil)
.scaleEffect(showDetail ? 1.5 : 1)
.animation(.spring())
此时,旋转动画将被清除(注意,仅仅是不以动画方式执行,但旋转仍然有效,只不过是瞬间旋转)。但在 animation(nil) 之后的缩放动画和弹簧动画仍然执行。
不对称动画
不对称动画属于 transition 动画中的一种,但是它的进入和退出动画是非对称的。对于对称的过渡动画(转场动画,transition 动画),它的入场方式和出场方式是对称的,比如从左边进入,从左边退出。一般退出动画和进入动画是做相反运动,在这种情况下,我们只需指定入场方式即可,出场方式 SwiftUI 会自动根据入场方式计算。但对于非对称动画,Swift UI 无法通过入场方式推断出场方式,因此你必须同时指定入场动画和出场动画:
Image(...)
.transition(
.asymmetric(
insertion: .move(edge:.trailing),
removal: .scale
)
)
这里,Image 将从屏幕右侧滑入,但退出时则是逐渐缩小至不可见。
组合动画
combined 修饰符可以将两个动画组合成一个,形成一种1+1的效果:
Image(...)
.transition(
AnyTransition.move(edge:.trailing).combined(with:.opacity)
)
从右边滑入+渐入效果。
AnyTransition
transition 修饰符中用了一个参数,比如我们用过的 move,scale,opacity 等,它们代表了不同的 transition 动画特效,但无一例外,统统都是 AnyTransition 类型。我们实际上可以自己扩展 AnyTransition 类型,从而定义自己的动画特效:
extension AnyTransition
static var moveAndScale: AnyTransition
AnyTransition.move(edge: .trailing).combined(with: .opacity)
static var myTransition: AnyTransition
AnyTransition.asymmetric(
insertion: .move(edge:trailing),
removal: .scale()以上是关于SwiftUI一日速成的主要内容,如果未能解决你的问题,请参考以下文章