SwiftUI简介
SwiftUI是wwdc2019发布的一个新的UI框架,通过声明和修改视图来布局UI和创建流畅的动画效果。并且我们可以通过状态变量来进行数据绑定实现一次性布局;Xcode 11 内建了直观的新设计工具canvus,在整个开发过程中,预览可视化与代码可编辑性能同时支持并交互,让我们可以体验到代码和布局同步的乐趣;同时支持和UIkit的交互
设计工具canvus
- 开发者可以在canvus中拖拽控件来构建界面, 所编辑的内容会立刻反应到代码上
- 切换不同的视图文件时canvus会切换到不同的界面
- 点击左下角的按钮钉我们可以把视图固定在活跃页面
- 选中canvus中的控件command+click可以调出inspect布局控件的属性
- 点击右上角的+可以获取新的控件并拖拽到对应的位置
- 在live状态下我们可以在canvus中调试点击等可交互效果 但不能缩放视图大小
每次修改或者增加属性需要点击resume刷新canvus
文件结构
创建一个SwiftUI文件,默认生成两个结构体。一个实现view的协议,在body属性里描述内容和布局;一个结构体声明预览的view
并进行初始化等信息,预览view是控制器的view时可以显示在多个模拟器设备,是控件view时可以设置frame,预览view是提供给canvus展示的
,使用了#if DEBUG 指令,编译器会删除代码,不会随应用程序一起发布
struct LandmarksList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"].identified(by: \.self)) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
.previewDisplayName(deviceName)
//.previewLayout(.fixed(width: 300, height: 70)) 设置view控件大小
}
.environmentObject(UserData())
}
}
#endif
布局
普通的view:将多个视图组合并嵌入到堆栈中,这些堆栈将视图水平、垂直或者前后组合在一起
VStack { //这里的布局实现的是上图canvus中landMarkDetail的效果
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 300)//不传width默认长度为整个界面
CircleImage(image: landmark.image(forSize: 250))
.offset(x: 0, y: -130)
.padding(.bottom, -130)
VStack(alignment: .leading) {
Text(landmark.name)
.font(.title)
HStack(alignment: .top) {
Text(landmark.park)
.font(.subheadline)
Spacer() //将水平的两个控件撑开
Text(landmark.state)
.font(.subheadline)
}
}
.padding()
Spacer()
}
列表的布局:要求数据是可被标识的
(1)唯一标识每个元素的主键路径
List(landmarkData.identified(by: \.id)) { landmark in
LandmarkRow(landmark: landmark)
}
(2)数据类型实现Identifiable protocol,持有一个id 属性
struct Landmark: Hashable, Codable, Identifiable {
var id: Int //
var name: String
fileprivate var imageName: String
fileprivate var coordinates: Coordinates
var state: String
var park: String
var category: Category
}
List(landmarkData) { landmark in
LandmarkRow(landmark: landmark)
} //直接传数据源
导航
添加导航栏是将其嵌入到NavigationView中,点击跳转的控件包装在navigationButton中,以设置到目标视图的换位。navigationBarTitle设置导航栏的标题,navigationBarItems设置导航栏右边的item
NavigationView {//显示导航view
List {
//SwiftUI里面的类似switch的控件,可以在list中直接组合布局
Toggle(isOn: $showFavoritesOnly) {
Text("Favorites only")
}
ForEach(landmarkData) { landmark in
if !self.showFavoritesOnly || landmark.isFavorite {
//跳转到地标详细页面
NavigationButton(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))//导航标题
}
}
实现modal出一个view
.navigationBarItems(trailing:
//点击navigationBarItems modal出profileHost页面
PresentationButton(
Image(systemName: "person.crop.circle")
.imageScale(.large)
.accessibility(label: Text("User Profile"))
.padding(),
destination: ProfileHost()
)
)
程序运行是从sceneDelegate定义的根视图开始的, UIhostingController 是UIViewController的子类
动画效果
SwiftUI包括带有预定义或自定义的基本动画 以及弹簧和流体动画,可以调整动画速度,设置延迟,重复动画等等
可以通过在一个动画修改器后面添加另一个动画修改器来关闭动画
- 转场动画
系统转场动画调用:hikeDetail(hike.hike).transition(.slide)
自定义的转场动画:把转场动画作为AnyTransition类的类型属性 (方便点语法设置丰富自定义动画)
extension AnyTransition {
static var moveAndFade: AnyTransition {
let insertion = AnyTransition.move(edge: .trailing)
.combined(with: .opacity)
let removal = AnyTransition.scale()
.combined(with: .opacity)
return .asymmetric(insertion: insertion, removal: removal)
}
}
HikeDetail(hike: hike).transition(.moveAndFade)
调用转场动画;move(edge:)方法是让视图从同一边滑出来以及消失;asymmetric(insertion:removal:)设置出现和小时的不同的动画效果
- 阻尼动画
var animation: Animation { //定义成存储属性方便调用
Animation.spring(initialVelocity: 5)//重力效果,值越大,弹性越大
.speed(2)//动画时间,值越大动画速度越快
.delay(0.03 * Double(index))
}
- 基础动画
Button(action: //点击按钮显示一个view带转场的动画效果
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
//旋转90度
.rotationEffect(.degrees(showDetail ? 90 : 0))
//.animation(nil) //关闭前面的旋转90度的动画效果,只显示下面的动画
//选中的时候放大为原来的1.5倍
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
// .animation(.basic()) 实现简单的基础动画
//.animation(.spring()) 阻尼动画
}
给图片按钮加动画效果, 对应的会有旋转和缩放会有动画;加到action时,即使点击完成后的显示没有给image的可做动画属性加动画效果,全部都有动画,包含旋转缩放和转场动画
数据流
利用SwiftUI环境中的存储 ,把自定义数据对象绑定到view ,SwiftUI监视到可绑对象任何影响视图的更改并在更改后显示正确的视图
- 自定义绑定类型
声明为绑定类型 BindableObject ,PassthroughSubject是Combine框架的消息发布者, SwiftUI通过这个消息发布者订阅对象,并在数据发生变化的时候更新任何需要刷新的视图
import Combine
import SwiftUI
final class UserData: BindableObject {
let didChange = PassthroughSubject()
var showFavoritesOnly = false {
didSet {
didChange.send(self)
}
}
var landmarks = landmarkData {
didSet {
didChange.send(self)
}
}
}
当客户机需要更新数据的时候,可绑定对象通知其订阅者
eg:当其中一个属性发生更改时,在属性的didset里面通过didchange发布者发布更改
- 绑定属性
(1)state
@State var profile = Profile.default
状态是随时间变化影响页面布局内容和行为的值
给定类型的持久值,视图通过该持久值读取和监视该值。状态实例不是值本身;它是读取和修改值的一种方法。若要访问状态的基础值,请使用其值属性。
(2)binding
@Binding var profile: Profile//向子视图传递数据
(3)environmentObject :
@EnvironmentObject var userData: UserData
存储在当前环境中的数据,跨视图传递
,在初始化持有对象的时候使用environmentObject(_:)赋值可以和前面的自定义绑定类型一起使用
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UIHostingController(rootView: CategoryHome().environmentObject(UserData()))
- 绑定行为
是对可变状态或数据的引用,用$的前缀访问状态变量或者其属性之一实现绑定控件 也可以访问绑定属性来实现绑定
与UIkit的交互
表示UIkit的view和controller 需要创建遵UIViewRepresentable或者UIViewControllerRepresentable协议
的结构体,SwiftUI管理他们的生命周期并在需要的时候更新
实现协议方法:
//创建展示的UIViewController,调用一次
func makeUIViewController(context: Self.Context) -> Self.UIViewControllerType
//将展示的UIViewController更新到最新的版本
func updateUIViewController(_ uiViewController: Self.UIViewControllerType, context: Self.Context)
//创建协调器
func makeCoordinator() -> Self.Coordinator
在结构体内嵌套定义一个coordinator类。SwiftUI管理coordinator并把它提供给context ,在makeUIView(context:)之前调用这个makeCoordinator()方法创建协调器
,以便在配置视图控制器的时候可以访问coordinator对象
我们可以使用这个协调器来实现常见的Cocoa模式,例如委托、数据源和通过目标操作响应用户事件
。
这里以用UIPageViewController实现轮播图为例,要注意其中的更新页面的逻辑~
pageview作为主view,组合一个PageControl 和 PageViewController实现图片轮播效果
PageView: @State var currentPage = 1
定义绑定属性 ,$currentPage
实现绑定到PageViewController
PageViewController: @Binding var currentPage: Int
定义绑定属性,在更新的方法updateUIViewController
里面绑定显示,点击pagecontrol的更新页面时pageviewcontroller可以更新到最新的页面
pagecontrol: @Binding var currentPage: Int
定义绑定属性 ,updateUIView
绑定显示,pageview滑动更新页面 pagecontrol可以更新到正确的显示
struct PageView: View {
var viewControllers: [UIHostingController]
@State var currentPage = 1
init(_ views: [Page]) {//传入的view用SwiftUI的controller包装好后面传给pagecontroller
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}
var body: some View {
ZStack(alignment: .bottomTrailing) {//将currentpage绑定起来了
PageViewController(controllers: viewControllers, currentPage: $currentPage)
PageControl(numberOfPages: viewControllers.count, currentPage: $currentPage)
.padding()
//Text("Current Page: \(currentPage)").padding(.trailing,30)
}
}
}
import SwiftUI
import UIKit
struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
@Binding var currentPage: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator
pageViewController.delegate = context.coordinator
return pageViewController
}
func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
//pageviewcontroller绑定currentpage显示当前的页面,pageView变化的时候,page更新页面
pageViewController.setViewControllers(
[controllers[currentPage]], direction: .forward, animated: true)
}
class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var parent: PageViewController
init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}
//左滑显示控制
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}
// 右滑动显示控制
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed,
let visibleViewController = pageViewController.viewControllers?.first,
let index = parent.controllers.firstIndex(of: visibleViewController) {
//当view滑动停止的时候告诉pageview当前页面的index(数据变化 pageview更新pagecontrol的展示)
parent.currentPage = index
}
}
}
}
struct PageControl: UIViewRepresentable {
var numberOfPages: Int
@Binding var currentPage: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = numberOfPages
control.addTarget(
context.coordinator,
action: #selector(Coordinator.updateCurrentPage(sender:)),
for: .valueChanged)
return control
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
}
class Coordinator: NSObject {
var control: PageControl
init(_ control: PageControl) {
self.control = control
}
@objc
func updateCurrentPage(sender: UIPageControl) {
control.currentPage = sender.currentPage
}
}
}
: 当我们编辑一部分用户数据的时候,我们不希望在编辑数据完成的时候影响到其他的页面 那么我们需要创建一个副本数据, 当副本数据编辑完成的时候 用副本数据更新真正的数据, 使相关的页面变化 这部分的内容参见demo中profiles的部分;对于画图的部分demo中也有非常酷炫的示例,详情参见 HikeGraph
、Badge
(徽章)
参考资料
Apple官网教程 :https://developer.apple.com/tutorials/swiftui/creating-and-combining-views
demo下载
SwiftUI documentation
作者简介
就职于甜橙金融(翼支付)信息技术部,负责 iOS 客户端开发