第八篇 SwiftUI Landmarks 与UIkit建立接口

1,874 阅读4分钟

SwiftUI与UIkit建立接口

SwiftUI与所有苹果平台上的现有UI框架无缝配合。例如,可以将UIKit视图和视图控制器放置在SwiftUI视图中,反之亦然。 本教程向您展示如何将主屏幕上的特色地标转换为包装UIPageViewController和UIPageControl的实例。您将使用UIPageViewController来显示SwiftUI视图的旋转木马,并使用状态变量和绑定来协调整个用户界面中的数据更新。
按照步骤构建此项目,或下载完成的项目自行探索。

第一节 创建视图以表示UIPageViewController

要在SwiftUI中表示UIKit视图和视图控制器,您可以创建符合UIViewRepresentable和UIViewControllerRepresentaable协议的类型。您的自定义类型创建和配置它们所代表的UIKit类型,而SwiftUI管理它们的生命周期并在需要时更新它们。

第一步 创建PageViewController.swift

在Views分组下新增一个分组PageView,创建一个swift文件PageViewController.swift,定义一个遵守UIViewControllerRepresentable协议的结构体PageViewController
页面视图控制器存储页面实例的数组,该数组必须是视图的类型。这些是您用来在地标之间滚动的页面。接下来添加UIViewControllerRepresentable协议的两个要求

import Foundation
import SwiftUI

struct PageViewController<Page: View>: UIViewControllerRepresentable {
    typealias UIViewControllerType = UIPageViewController
    var pages: [Page]   
}

第二步 添加实现makeUIViewController(context:)

添加一个makeUIViewController(context:)方法,该方法可以创建具有所需配置的UIPageViewController。 SwiftUI在准备好显示视图时一次性调用此方法,然后管理视图控制器的生命周期。

func makeUIViewController(context: Context) -> UIPageViewController {

    let pageViewController = UIPageViewController(
        transitionStyle: .scroll,
        navigationOrientation: .horizontal)

    return pageViewController
}

第三步 实现updateUIViewController(_:context:)

添加一个updateUIViewController(:context:)方法,该方法调用setViewControllers(:direction:animated:)以提供用于显示的视图控制器。
现在,您要创建UIHostingController,它在每次更新时托管页面SwiftUI视图。稍后,您将通过在页面视图控制器的生命周期内只初始化一次控制器来提高效率。

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {

    pageViewController.setViewControllers([UIHostingController(rootView: pages[0])], direction: .forward, animated: true)

}

第四步 添加特色地标的图片资源

将下载的项目文件的资源目录中的图像拖到应用程序的资产目录中。
地标的特征图像(如果存在)与常规图像具有不同的尺寸。

第五步 将计算特性添加到Landmark结构中,该结构将返回特征图像(如果存在)。

var featureImage: Image? {
    isFeatured ? Image(imageName + "_feture") : nil
}

第六步 创建一个展示特色地标图片的视图

创建swiftUI视图FeatureCard.swift,用来展示特色地标图片

import SwiftUI

struct FeatureCard: View {
    var landmark: Landmark
    var body: some View {
        landmark.featureImage?
            .resizable()
            .aspectRatio(3.0/2.0, contentMode: .fit)
    }
}

struct FeatureCard_Previews: PreviewProvider {
    static var previews: some View {
        FeatureCard(landmark: ModelData().features[0])
    }
}

第七步 在图像上叠加有关地标的文本信息。


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()
        }
        .foregroundColor(.white)
    }
    
}

第八步 创建一个视图用来展示UIViewControllerRepresentable视图

创建一个SwiftUI视图PageView.swift,并更新PageView类型以将PageViewController声明为子视图。
预览失败,因为Xcode无法推断Page的类型。

第九步 更新预览提供程序以传递所需的视图数组,预览即可开始工作。

import SwiftUI

struct PageView<Page: View>: View {
    
    var pages: [Page]
    
    var body: some View {
        PageViewController(pages: pages)
    }
}

struct PageView_Previews: PreviewProvider {
    static var previews: some View {
        PageView(pages: ModelData().features.map { FeatureCard(landmark: $0)})
            .aspectRatio(3/2, contentMode: .fit)
    }
}

第二节 创建视图控制器的数据源

在短短的几个步骤中,您已经完成了很多工作——PageViewController使用UIPageViewController来显示SwiftUI视图中的内容。现在是时候让滑动交互从一页移动到另一页了。

一个代表UIKit视图控制器的SwiftUI视图,可以定义一个由SwiftUI管理和提供并作为视图上下文一部分的协调类Coordinator

第一步 声明一个协调类

在PageViewController内部声明一个嵌套协调类Coordinator。
SwiftUI管理UIViewControllerRepresentable类型的协调器,并在调用上面创建的方法时将其作为上下文的一部分提供。


class Coordinator: NSObject {
    var parent: PageViewController

    init(_ pageViewController: PageViewController) {
        self.parent = pageViewController
    }
}

第二步 协调器生成方法

将另一个方法添加到PageViewController以生成协调器。

func makeCoordinator() -> Coordinator {
    Coordinator(self)
}

SwiftUI在makeUIViewController(context:)之前调用这个makeCoordinator()方法,以便在配置视图控制器时可以访问coordinator对象。
您可以使用此协调器来实现常见的Cocoa模式,例如委托、数据源以及通过目标操作响应用户事件。

第三步 使用视图的页面数组初始化协调器中的控制器数组。

协调器是存储这些控制器的好地方,因为系统只初始化它们一次,并且在您需要它们更新视图控制器之前。

第四步 遵守UIPageViewControllerDataSource协议

协调器遵守UIPageViewControllerDataSource协议,并实现两个必需的方法。这两种方法建立了视图控制器之间的关系,以便您可以在它们之间来回滑动。

第五步 添加协调器作为UIPageViewController的数据源代理

第一步、第二步、第三步、第四步、第五步 代码总和

import Foundation
import SwiftUI

struct PageViewController<Page: View>: UIViewControllerRepresentable {
    
    typealias UIViewControllerType = UIPageViewController
    
    var pages: [Page]
    
    
    //生成协调器
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    //生成UIPageViewController
    func makeUIViewController(context: Context) -> UIPageViewController {
        
        let pageViewController = UIPageViewController(
            transitionStyle: .scroll,
            navigationOrientation: .horizontal)
        
        pageViewController.dataSource = context.coordinator
        
        return pageViewController
    }
    
    //更新UIPageViewController,切丁第一个要展示的视图控制器
    func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
        
        pageViewController.setViewControllers([context.coordinator.controllers[1]], direction: .forward, animated: true)
        
    }
    
    
    class Coordinator: NSObject, UIPageViewControllerDataSource {
        
        var parent: PageViewController
        var controllers = [UIViewController]()
        
        init(_ pageViewController: PageViewController) {
            
            self.parent = pageViewController
            controllers = parent.pages.map { UIHostingController(rootView: $0) }
        }
        
        // MARK: - UIPageViewControllerDataSource
        
        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]
        }
    }
    
    
}

第六步 回到PageView,开启预览,测试滑动操作

第三节 在SwiftUI视图的状态下跟踪页面

要准备添加自定义UIPageControl,您需要一种从PageView中跟踪当前页面的方法。 为此,您将在PageView中声明一个@State属性,并将该属性的绑定传递到PageViewController视图。PageViewController会更新绑定以匹配可见页面。

第一步 添加currentPage

在PageViewController中添加一个绑定属性currentPage。

@Binding var currentPage: Int

除了声明@Binding属性外,还更新对setViewControllers(_:direction:animated:)的调用,传递currentPage绑定的值。

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
 pageViewController.setViewControllers([context.coordinator.controllers[currentPage]], direction: .forward, animated: true)

}

第二步 声明@State变量

在PageView中声明一个currentPage属性,且用@State修饰,在创建PageViewController时传递过去。\

@State private var currentPage = 0

使用$将状态存储属性与绑定属性关联起来

PageViewController(pages: pages, currentPage: $currentPage)

第三步 改变currentPage的值来测试PageViewController

添加一个带有currentPage属性的文本视图,这样您就可以关注@State属性的值。

VStack {

    PageViewController(pages: pages, currentPage: $currentPage)

    Text("Current Page: \(currentPage)")

}

请注意,当您从一页滑动到另一页时,该值不会改变。

第四步 协调器遵守UIPageViewControllerDelegate

在PageViewController.swift中,使协调器符合UIPageViewController Delegate,并添加PageViewController(_:didFinishAnimationing:previousViewControllers:transitionCompleted-completed:Bool)方法。
由于SwiftUI在页面切换动画完成时调用此方法,因此可以找到当前视图控制器的索引并更新绑定。


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
    }
}

第六步 将协调器赋值给UIPageViewController的delegate

除了数据源之外,还将协调器分配为UIPageViewController的委托。 在绑定双向连接的情况下,每次滑动后,文本视图都会更新以显示正确的页码。

pageViewController.delegate = context.coordinator

第四节 添加自定义页面控件

您已经准备好将自定义UIPageControl添加到视图中,该控件封装在SwiftUI UIViewRepresentable视图中。

第一步 创建PageControl

创建一个SwiftUI视图PageControl,遵守UIViewRepresentable协议
UIViewRepresentable和UIViewControllerRepresentaable类型具有相同的生命周期,其方法与它们的底层UIKit类型相对应。

import SwiftUI

struct PageControl: UIViewRepresentable {
    
    typealias UIViewType = UIPageControl
    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
    }  
}

第二步 用页面控件替换文本框

从VStack切换到ZStack进行布局。 因为您正在将页面计数和绑定传递到当前页面,所以页面控件已经显示了正确的值。

ZStack(alignment: .bottomTrailing) {
    PageViewController(pages: pages, currentPage: $currentPage)

    PageControl(numberOfPages: pages.count, currentPage: $currentPage)

        .frame(width: CGFloat(pages.count * 18))

        .padding(.trailing)
}

接下来,使页面控件具有交互性,这样用户就可以点击一侧或另一侧在页面之间移动。

第三步 创建协调器

在PageControl中创建一个嵌套的Coordinator类型,并添加一个makeCoordinators()方法来创建和返回一个新的协调器。
因为像UIPageControl这样的UIControl子类使用目标操作模式而不是委派,所以这个Coordinator实现了一个@objc方法来更新当前页面绑定。

class Coordinator: NSObject {

    var control: PageControl

    init(_ control: PageControl) {
        self.control = control
    }

    @objc
    func updateCurrentPage(sender: UIPageControl) {
        control.currentPage = sender.currentPage
    }

}

第四步 绑定valueChanged事件

添加协调器作为valueChanged事件的目标,指定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
}

第五步 在CategoryHome中,将占位符功能图像替换为新的页面视图。

import SwiftUI

struct CategoryHome: View {
    @EnvironmentObject var modelData: ModelData
    @State private var showProfile = false
    
    var body: some View {
        NavigationView {
            List {
                PageView(pages: modelData.features.map { FeatureCard(landmark: $0) })
                    .aspectRatio(3 / 2, contentMode: .fit)
                    .listRowInsets(EdgeInsets())
                
                ForEach(modelData.categories.keys.sorted(), id: \.self) { key in
                    CategoryRow(categoryName: key, items: modelData.categories[key]!)
                }
                .listRowInsets(EdgeInsets())
            }
            .listStyle(.inset)
            .navigationTitle(Text("Featured"))
            .toolbar {
                Button {
                    showProfile.toggle()
                } label: {
                    Label("User Profile", systemImage: "person.crop.circle")
                }
            }
            .sheet(isPresented: $showProfile) {
                ProfileHost().environmentObject(modelData)
            }
            
        }
    }
}

struct CategoryHome_Previews: PreviewProvider {
    static var previews: some View {
        CategoryHome().environmentObject(ModelData())
    }
}

尝试所有不同的交互-PageView展示了UIKit和SwiftUI视图和控制器如何协同工作。

总结

到这里,Landmarks这个小项目的所有功能就算完成了。
同学们有哪里不清楚或作者有哪里写的不对的地方,欢迎评论区提意见或讨论