代码下载
SwiftUI可以在苹果全平台上无缝兼容现有的UI框架。例如,可以在SwiftUI视图中嵌入UIKit视图或UIKit视图控制器,反过来在UIKit视图或UIKit视图控制器中也可以嵌入SwiftUI视图。
本文展示如何把landmark应用的主页混合使用UIPageViewController和UIPageControl。使用UIPageViewController来展示由SwiftUI视图构成的轮播图,使用状态变量和绑定来操作用户界面数据的更新。
下载起步项目并跟着本篇教程一步步实践,或者查看本篇完成状态时的工程代码去学习,项目文件。
创建一个用来展示UIPageViewController的SwiftUI视图
为了在SwiftUI视图中展示UIKit视图和UIKit视图控制器,需要创建遵循 UIViewRepresentable 和 UIViewControllerRepresentable 协议的类型。创建的自定义视图类型,用来创建和配置所要展示的UIKit类型,SwiftUI框架来管理UIKIt类型的生命周期并在适当的时机更新它们。
1、创建一个新的 SwiftUI 视图文件,命名为 PageViewController.swift,并且声明 PageViewController 类型遵循 UIViewControllerRepresentable 协议。这个页面视图控制器存放一个 UIViewController 实例数组,数组中的每一个元素代表在地标滚动过程中的一页视图。
import SwiftUI
import UIKit
struct PageViewController<Page: View>: UIViewControllerRepresentable {
var pages: [Page]
}
下一步添加UIViewControllerRepresentable协议的两个实现, 目前因为协议方法没有完成实现,会有报错提示。
2、添加一个 makeUIViewController(context:)
方法,方法内部以指定的配置创建一个 UIPageViewController。SwiftUI 会在准备显示视图时调用一次 makeUIViewController(context:)
方法创建 UIViewController 实例,并管理它的生命周期。
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
return pageViewController
}
3、添加 updateUIViewController(_:context:)
方法,这个方法里调用 setViewControllers(_:direction:animated:)
方法展示数组中的第一个视图控制器。
func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[UIHostingController(rootView: pages[0])], direction: .forward, animated: true)
}
现在,创建了 UIHostingController,它在每次更新时托管页面SwiftUI视图。稍后将通过在页面视图控制器的生命周期中只初始化一次控制器来提高效率。
4、将下载的项目文件 Resources
目录中的图片拖到应用程序的 Asset
目录中。地标的特征图像,如果存在的话,其维度与常规图像不同。
5、将计算属性添加到返回特征图像(如果存在)的 Landmark 结构中。
var featureImage: Image? {
isFeatured ? Image(imageName + "_feature") : nil
}
6、添加一个新的 SwiftUI 视图文件,名为 FeatureCard.swift,用于显示地标的特征图像。
import SwiftUI
struct FeatureCard: View {
var landmark: Landmark
var body: some View {
landmark.featureImage?
.resizable()
}
}
#Preview {
FeatureCard(landmark: ModelData().features[0])
.aspectRatio(3 / 2, contentMode: .fit)
}
包括长宽比修改器,这样它就可以模仿视图的长宽比,而 FeatureCard 稍后将预览该视图最终的样子。
7、在图像上叠加有关地标的文本信息。
struct FeatureCard: View {
var landmark: Landmark
var body: some View {
landmark.featureImage?
.resizable()
.overlay {
TextOverlay(landmark: landmark)
}
}
}
struct TextOverlay: View {
var landmark: Landmark
var gradient: LinearGradient {
.linearGradient(
Gradient(colors: [.black.opacity(0.6), .black.opacity(0)]),
startPoint: .bottom,
endPoint: .center)
}
var body: some View {
ZStack(alignment: .bottomLeading) {
gradient
VStack(alignment: .leading) {
Text(landmark.name)
.font(.title)
.bold()
Text(landmark.park)
}
.padding()
}
.foregroundStyle(.white)
}
}
接下来,创建另一个SwiftUI视图展示遵循UIViewControllerRepresentable协议的视图。
8、创建一个名为PageView.swift的视图,声明一个PageViewController作为子视图。初始化时使用一个视图数组来初始化,并把每一个视图都嵌入在一个UIHostingController中。UIHostingController是一个UIViewController的子类,用来在UIKit环境中表示一个SwiftUI视图。
struct PageView<Page: View>: View {
var pages: [Page]
var body: some View {
PageViewController(pages: pages)
}
}
#Preview {
PageView()
}
预览失败是因为Xcode无法推断Page的类型。
9、添加宽高比修改器,更新预览视图,并传入视图数组,预览视图就会开始工作了。
struct PageView<Page: View>: View {
var pages: [Page]
var body: some View {
PageViewController(pages: pages)
.aspectRatio(3 / 2, contentMode: .fit)
}
}
#Preview {
PageView(pages: ModelData().features.map { FeatureCard(landmark: $0) })
}
创建视图控制器的数据源
短短几个步骤就做了很多事,PageViewController 使用 UIPageViewController 去展示来自 SwiftUI 内容。现在是时候添加扫动手势进行页面之间的滚动了。
一个展示 UIKit 视图控制器的 SwiftUI 视图可以定义一个 Coordinator 类型,这个 Coordinator 类型由SwitUI管理,用来作为视图展示的上下文。
1、在 PageViewControlelr 中定义一个嵌套类型 Coordiantor。SwiftUI管理 UIViewControllerRepresentable 类型的 coordinator,并在调用方法时把它作为上下文的一部分。
class Coordinator: NSObject {
var parent: PageViewController
init(_ pageViewController: PageViewController) {
parent = pageViewController
}
}
2、在 PageViewController 中添加另一个方法,创建coordinator。SwiftUI在调用 makeUIViewController(context:)
前会先调用 makeCoordinator()
方法,因此在配置视图控制器时是可以访问到coordiantor对象的。
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
可以使用coordinator为实现通用的Cocoa模式,例如:代理模式、数据源以及目标-动作。
3、在 Coordinator 中使用 pages 的视图数组初始化控制器数组。
func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[context.coordinator.controllers[0]], direction: .forward, animated: true)
}
class Coordinator: NSObject {
var parent: PageViewController
var controllers = [UIViewController]()
init(_ pageViewController: PageViewController) {
parent = pageViewController
controllers = parent.pages.map { UIHostingController(rootView: $0) }
}
}
Coordinator 是存储这些控制器的好地方,因为系统只初始化它们一次,并且在需要它们更新视图控制器之前。
4、让 Coordinator 类型遵循 UIPageViewControllerDataSource 协议,并且实现两个必要方法。这两个必要方法会建立起视图控制器之间的联系,因此可以实现页面之前的前后切换。
class Coordinator: NSObject, UIPageViewControllerDataSource {
var parent: PageViewController
var controllers = [UIViewController]()
init(_ pageViewController: PageViewController) {
parent = pageViewController
controllers = parent.pages.map { UIHostingController(rootView: $0) }
}
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController?
{
guard let index = controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return controllers.last
}
return controllers[index - 1]
}
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController?
{
guard let index = controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == controllers.count {
return controllers.first
}
return controllers[index + 1]
}
}
5、把coordiantor作为UIPageViewController的数据源。
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator
return pageViewController
}
打开实时预览,并测试一下 PageView 前后页面切换的功能是否正常。
在SwiftUI视图的状态下跟踪页面
如果要添加一个自定义的 UIPageControl 控件,就需要一种方式能够在 PageView 中跟踪当前展示的页面。
这就需要在 PageView 中声明一个 @State
属性,并传递一个针对该属性的绑定关系给 PageViewController 视图,在 PageViewController 中通过绑定关系更新状态属性,来反映当前展示的页面。
1、在 PageViewController 中添加一个绑定属性 currentPage。除了使用关键字 @Binding
声明属性为绑定属性外,还需要更新一下函数 setViewControllers(_:direction:animated:)
,给它传入 currentPage 绑定属性。
@Binding var currentPage: Int
func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[context.coordinator.controllers[currentPage]], direction: .forward, animated: true)
}
2、在PageView中声明 @State
变量,并在创建 PageViewController 时把绑定属性传入。注意使用 $
语法创建一个针对状态变量的绑定关系。
struct PageView<Page: View>: View {
var pages: [Page]
@State private var currentPage = 0
var body: some View {
PageViewController(pages: pages, currentPage: $currentPage)
.aspectRatio(3 / 2, contentMode: .fit)
}
}
3、通过改变 PageView 视图中的 currentPage 初始值来测试绑定关系是否正常生效。也可以做一个测试按钮,点击按钮时让第二个页面展示出来。
@State private var currentPage = 1
4、添加一个TextView控件来展示状态变量currentPage的值,拖动页面切换时观察TextView上的值,目前不会发生变化。因为PageViewController内部没有在切换页面的过程中更新currentPage的值。
struct PageView<Page: View>: View {
var pages: [Page]
@State private var currentPage = 0
var body: some View {
VStack {
PageViewController(pages: pages, currentPage: $currentPage)
Text("Current Page: \(currentPage)")
}
.aspectRatio(3 / 2, contentMode: .fit)
}
}
5、在 PageViewController.swift 中让 coordinator 作为 UIPageViewController 的代理,并添加p ageViewController(_:didFinishAnimating:previousViewControllers:transitionCompleted completed: Bool)
方法。因为 SwiftUI 在页面切换动画完成时会调用这个方法,这样就可以这个方法内部获取当前正在展示的页面的下标,并同时更新绑定属性currentPage的值。
class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
func pageViewController(
_ pageViewController: UIPageViewController,
didFinishAnimating finished: Bool,
previousViewControllers: [UIViewController],
transitionCompleted completed: Bool) {
if completed,
let visibleViewController = pageViewController.viewControllers?.first,
let index = controllers.firstIndex(of: visibleViewController) {
parent.currentPage = index
}
}
...
6、coordinator 除了是 UIPageViewController 数据源外,再把它赋值为UIPageViewController的代理。由于绑定关系是双向的,所以当页面切换时,PageView视图上的Text就会实时展示当前的页码。
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator
pageViewController.delegate = context.coordinator
return pageViewController
}
添加一个自定义PageControl
准备为包裹在 UIViewRepresentable 视图中的子视图上添加了一个自定义 UIPageControl。
1、创建一个新的 SwiftUI 视图,命名为 PageControl.swift,并使 PageControl 类型遵循 UIViewRepresentable 协议。UIViewRepresentable 和 UIViewControllerRepresentable 类型有相同的生命周期,在 UIKit 类型中都有对应的生命周期方法。
struct PageControl: UIViewRepresentable {
var numberOfPages: Int
@Binding var currentPage: Int
func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = numberOfPages
return control
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
}
}
2、在 PageView 中用 PageControl 替换 Text ,并把 VStack 换成 ZStack 。因为总页数和当前页面都已经传入 PageControl,所以 PageControl 已经可以正确的显示。
var body: some View {
ZStack(alignment: .bottomTrailing) {
PageViewController(pages: pages, currentPage: $currentPage)
PageControl(numberOfPages: pages.count, currentPage: $currentPage)
.frame(width: CGFloat(pages.count * 18))
.padding(.trailing)
}
.aspectRatio(3 / 2, contentMode: .fit)
}
下一步要处理PageControl与用户的交互,让它可以被用户点击任意一边进行页面间的切换。
3、在 PageControl 中创建一个嵌套类型 Coordiantor,添加一个 makeCoordinator()
方法创建并返回一个 coordinator 实例。因为 UIControl 子类(包括 UIPageControl)使用 Target-Action
模式,Coordinator 实现一个 @objc
方法来更新 currentPage 绑定属性的值。
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject {
var control: PageControl
init(_ control: PageControl) {
self.control = control
}
@objc
func updateCurrentPage(sender: UIPageControl) {
control.currentPage = sender.currentPage
}
}
4、把 coordinator 作为 PageControl 值改变事件的目标处理器,并指定 updateCurrentPage(sender:)
方法为处理函数。
func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = numberOfPages
control.addTarget(
context.coordinator,
action: #selector(Coordinator.updateCurrentPage(sender:)),
for: .valueChanged)
return control
}
5、最后,在 CategoryHome 中,用新的页面视图替换占位符特征图像。
struct CategoryHome: View {
@Environment(ModelData.self) var modelData
@State private var showingProfile = false
var body: some View {
NavigationSplitView {
List {
PageView(pages: modelData.features.map { FeatureCard(landmark: $0) })
.listRowInsets(EdgeInsets())
ForEach(modelData.categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: modelData.categories[key]!)
}
.listRowInsets(EdgeInsets())
}
.listStyle(.inset)
.navigationTitle("Featured")
.toolbar {
Button {
showingProfile.toggle()
} label: {
Label("User Profile", systemImage: "person.crop.circle")
}
}
.sheet(isPresented: $showingProfile) {
ProfileHost()
.environment(modelData)
}
} detail: {
Text("Select a Landmark")
}
}
}
6、现在就可以尝试 PageControl 的各种交互来切换页面,PageView展示了SwiftUI和UIKit视图如何混合使用。