UIKit框架(三十六) —— UICollectionView UICollectionViewDiffableDataSource的使用(一)

版本记录

版本号 时间
V1.0 2020.04.28 星期二

前言

iOS中有关视图控件用户能看到的都在UIKit框架里面,用户交互也是通过UIKit进行的。感兴趣的参考上面几篇文章。
1. UIKit框架(一) —— UIKit动力学和移动效果(一)
2. UIKit框架(二) —— UIKit动力学和移动效果(二)
3. UIKit框架(三) —— UICollectionViewCell的扩张效果的实现(一)
4. UIKit框架(四) —— UICollectionViewCell的扩张效果的实现(二)
5. UIKit框架(五) —— 自定义控件:可重复使用的滑块(一)
6. UIKit框架(六) —— 自定义控件:可重复使用的滑块(二)
7. UIKit框架(七) —— 动态尺寸UITableViewCell的实现(一)
8. UIKit框架(八) —— 动态尺寸UITableViewCell的实现(二)
9. UIKit框架(九) —— UICollectionView的数据异步预加载(一)
10. UIKit框架(十) —— UICollectionView的数据异步预加载(二)
11. UIKit框架(十一) —— UICollectionView的重用、选择和重排序(一)
12. UIKit框架(十二) —— UICollectionView的重用、选择和重排序(二)
13. UIKit框架(十三) —— 如何创建自己的侧滑式面板导航(一)
14. UIKit框架(十四) —— 如何创建自己的侧滑式面板导航(二)
15. UIKit框架(十五) —— 基于自定义UICollectionViewLayout布局的简单示例(一)
16. UIKit框架(十六) —— 基于自定义UICollectionViewLayout布局的简单示例(二)
17. UIKit框架(十七) —— 基于自定义UICollectionViewLayout布局的简单示例(三)
18. UIKit框架(十八) —— 基于CALayer属性的一种3D边栏动画的实现(一)
19. UIKit框架(十九) —— 基于CALayer属性的一种3D边栏动画的实现(二)
20. UIKit框架(二十) —— 基于UILabel跑马灯类似效果的实现(一)
21. UIKit框架(二十一) —— UIStackView的使用(一)
22. UIKit框架(二十二) —— 基于UIPresentationController的自定义viewController的转场和展示(一)
23. UIKit框架(二十三) —— 基于UIPresentationController的自定义viewController的转场和展示(二)
24. UIKit框架(二十四) —— 基于UICollectionViews和Drag-Drop在两个APP间的使用示例 (一)
25. UIKit框架(二十五) —— 基于UICollectionViews和Drag-Drop在两个APP间的使用示例 (二)
26. UIKit框架(二十六) —— UICollectionView的自定义布局 (一)
27. UIKit框架(二十七) —— UICollectionView的自定义布局 (二)
28. UIKit框架(二十八) —— 一个UISplitViewController的简单实用示例 (一)
29. UIKit框架(二十九) —— 一个UISplitViewController的简单实用示例 (二)
30. UIKit框架(三十) —— 基于UICollectionViewCompositionalLayout API的UICollectionViews布局的简单示例(一)
31. UIKit框架(三十一) —— 基于UICollectionViewCompositionalLayout API的UICollectionViews布局的简单示例(二)
32. UIKit框架(三十二) —— 替换Peek and Pop交互的基于iOS13的Context Menus(一)
33. UIKit框架(三十三) —— 替换Peek and Pop交互的基于iOS13的Context Menus(二)
34. UIKit框架(三十四) —— Accessibility的使用(一)
35. UIKit框架(三十五) —— Accessibility的使用(二)

开始

首先看下主要内容:

在本iOS教程中,您将学习如何使用UICollectionViewDiffableDataSourceNSDiffableDataSourceSnapshot实现collection view。来自翻译。

下面看下写作环境:

Swift 5, iOS 13, Xcode 11

在iOS 13中,苹果对UICollectionView API进行了重大更新:UICollectionViewDiffableDataSource。这个新的API比复杂、脆弱和容易出错的UICollectionViewDataSource API更加灵活和具有声明性。

在本教程中,您将学习如何:

  • 用新的UICollectionViewDiffableDataSource替换旧的UICollectionViewDataSource
  • 使用NSDiffableDataSourceSnapshot
  • 向数据源添加sections
  • 向数据源添加supplementary view

此外,您将看到使用这种新类型的数据源使更改具有动画效果是多么容易。我希望你对开始感到兴奋!

打开起始项目,这个项目被称为RayTube,它允许你浏览一系列的RayWenderlich视频课程,搜索一个特定的课程,点击它来查看更多的细节。

首先,打开启动项目。构建和运行。

你会看到一系列的RayWenderlich视频。点击视频查看详细信息。另外,尝试使用搜索栏通过标题搜索特定的视频。

同时,在执行搜索查询时,过滤后的视频不会被动画化。

显然,这并不理想。这个UI看起来断断续续的,这可能不是你想要的动画。你需要那个平滑的动画!在添加可扩散的数据源时,您也可以自动地解决这个问题。


What is UICollectionViewDiffableDataSource?

iOS 13之前,你需要配置UICollectionView的数据源通过采用UICollectionViewDataSource。这个协议告诉集合collection view什么单元格,显示多少单元格,在哪个section显示单元格,等等。

新的UICollectionViewDiffableDataSource抽象了大量的UICollectionViewDataSource的逻辑。这在处理collection view的数据源时为客户端代码错误留下更少的空间。

不是告诉数据源要显示多少项items,而是告诉它要显示哪些sections and items

UICollectionViewDiffableDataSourcediffable部分意味着,每当您更新正在显示的项items时,collection view将自动计算更新后的collection与先前显示的集合之间的差异。这又将导致collection view将更改(如更新、插入和删除)设置为动画。


Benefits of UICollectionViewDiffableDataSource

下面是实现UICollectionViewDiffableDataSource的三个好处:

  • 1) Automatic data change animations:自动数据更改动画,无论何时添加、更新或删除数据,都可以自动获得数据更改动画。
  • 2) Automatic data synchronization:自动数据同步,要利用没有UICollectionViewDiffableDataSourcecollection view的标准动画,您必须手动管理和同步collection view和数据源之间的数据更改。如果你有一个失调的同步操作,你会看到这样的错误:
  • 3) Reduced code:减少代码,总的来说,您可以编写更少的代码,并从collection view的数据更改动画和数据同步中获益。

很智能,对吧?那么,如何利用新的UICollectionViewDiffableDataSource呢? 下节课再详细介绍。


Creating a Diffable Data Source

UICollectionViewDiffableDataSource有两个通用类型:节类型和项类型(Section type and item type)。如果您以前使用过集合视图,那么应该熟悉sections and items的概念。

要创建你的section类型,在VideosViewController.swiftvideoList下面添加以下代码:

enum Section {
  case main
}

既然已经创建了Section enum,现在就该创建diffable data source

为了保持简洁,为数据源创建一个类型别名。这减少了在需要配置数据源以及每次需要引用相同数据类型时编写UICollectionViewDiffableDataSource值类型的需要。

section类型下面,编写以下代码来声明DataSource类型别名:

typealias DataSource = UICollectionViewDiffableDataSource

太棒了!构建和运行。

事实证明,您的视频数据类型需要符合Hashable


Implementing Hashable

当视频被添加、删除或更新时,Hashable允许diffable data source执行更新。为了知道两个元素是否相等,需要遵循该协议。

打开Video.swift。使Video遵循Hashable协议:

class Video: Hashable {

接下来,您需要实现协议方法。在init(title:thumbnail:lessonCount:link:)中添加以下代码:

// 1
func hash(into hasher: inout Hasher) {
  // 2
  hasher.combine(id)
}

// 3
static func == (lhs: Video, rhs: Video) -> Bool {
  lhs.id == rhs.id
}

你是这样做的:

  • 1) 实现了hash(into:),它散列给定的组件。
  • 2) 将视频Videoid添加到hash中。对于视频,只需要ID就可以知道两个视频是否相等。
  • 3) 实现了Equatable协议的==函数,因为所有可Hashable的对象也必须是Equatable

您的项目现在应该能够再次构建,没有任何错误。


Configuring The Diffable Data Source

打开VideosViewController.swift。现在,该Video遵循Hashable,您可以完成创建diffable data source

viewDidLoad()下面,添加以下代码:

func makeDataSource() -> DataSource {
  // 1
  let dataSource = DataSource(
    collectionView: collectionView,
    cellProvider: { (collectionView, indexPath, video) ->
      UICollectionViewCell? in
      // 2
      let cell = collectionView.dequeueReusableCell(
        withReuseIdentifier: "VideoCollectionViewCell",
        for: indexPath) as? VideoCollectionViewCell
      cell?.video = video
      return cell
  })
  return dataSource
}

在这里:

  • 1) 创建一个数据源,传入collectionViewcellProvider回调。
  • 2) 在cellProvider回调函数中,您返回一个VideoCollectionViewCell。您在这个函数中编写的代码与您在UICollectionViewDataSourcecollectionView(_:cellForItemAt:)中看到的代码相同。

现在您已经实现了makeDataSource(),您可以删除// MARK: - UICollectionViewDataSource下的数据源方法。具体来说,删除以下两个方法:

  • collectionView (_: numberOfItemsInSection:)
  • collectionView (_: cellForItemAt:)

您可以删除这些方法,因为diffable data source会自动为您处理这些函数。

接下来,是时候实际使用您辛苦使用的makeDataSource()了!

VideosViewController中,在顶部添加以下属性:

private lazy var dataSource = makeDataSource()

这将为collection view创建数据源。必须将其标记为lazy,因为在调用makeDataSource()之前,Swift需要VidoesViewController完成初始化。

最后,在collectionView(_:didSelectItemAt:)中替换:

let video = videoList[indexPath.row]

使用

guard let video = dataSource.itemIdentifier(for: indexPath) else {
  return
}

这确保应用程序直接从dataSource检索视频。这很重要,因为UICollectionViewDiffableDataSource可能在后台工作,使videoList与当前显示的数据不一致。

构建和运行。

collection view可以显示任何单元格之前,您需要告诉它要显示什么数据。这就是snapshots发挥作用的地方!


Using NSDiffableDataSourceSnapshot

NSDiffableDataSourceSnapshot存储diffable data source引用的sections and items,以了解要显示多少节和单元格。

就像您为UICollectionViewDiffableDataSource创建了类型别名一样,您也可以创建Snapshot类型别名。

添加以下类型别名到VideosViewController

typealias Snapshot = NSDiffableDataSourceSnapshot

NSDiffableDataSourceSnapshot,像UICollectionViewDiffableDataSource,接受一个section类型和一个item类型:sectionVideo

现在,该创建snapshot了!

viewDidLoad()下面,添加以下方法:

// 1
func applySnapshot(animatingDifferences: Bool = true) {
  // 2
  var snapshot = Snapshot()
  // 3
  snapshot.appendSections([.main])
  // 4
  snapshot.appendItems(videoList)
  // 5
  dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
}

使用applySnapshot (animatingDifferences:)你:

  • 1) 创建一个将snapshot应用于数据源的新方法。该方法接受一个布尔值,该布尔值决定对数据源的更改是否应该被动画。
  • 2) 创建一个新的Snapshot对象。
  • 3) 将.main部分添加到snapshot。这是您当前为应用程序定义的惟一section类型。
  • 4) 将视频数组添加到snapshot
  • 5) 告诉dataSource最新的snapshot,以便它可以相应地更新和动画。

太棒了!现在在viewDidLoad()的末尾调用这个方法:

applySnapshot(animatingDifferences: false)

构建和运行

它起作用了!但有一个小问题。搜索一些东西,你会发现用户界面根本没有更新。

幸运的是,修复搜索功能超级简单!


Fixing Search

内部updateSearchResults(:),替换:

collectionView.reloadData()

使用

applySnapshot()

不要重新加载整个collection view,而是将一个新的snapshot应用到数据库,这将导致更改进行动画。

构建和运行。在搜索栏中输入No,然后观看UI动画:

如果你在iPad上运行这个应用程序,动画会更复杂,而且是自然进行的,你不用多做什么。

成功!接下来,您将学习如何实现多个sections


Multiple Sections

有两种方法可以使用diffable data source API实现多个sections

1. Option One

还记得Section枚举吗?

enum Section {
  case main
}

您可以向enum添加另一种case来实现多个sections。当您想要显示一组预定义的sections时,此选项非常有用。例如,一个消息应用程序有一个friends section和一个others section

然而,如果你没有简单的方法知道你想要你的应用显示什么sections,选择二是为你!

2. Option Two

第二个选项是将Section从值类型更改为类。之后,您可以自由地创建任意数量的这些对象,而不必预先定义每个section

如果您的服务器提供可以随时更改的类别,或者允许用户动态创建sections,那么这个选项非常好。

因为RayTube应用程序有多个sections,而且这些sections可能会随着数据的改变而改变,所以您将使用这个选项。


Creating the Section Class

VideosViewController.swift,删除以下代码:

enum Section {
  case main
}

接下来,创建一个名为Section.swift的新文件。添加以下代码到文件:

import UIKit
// 1
class Section: Hashable {
  var id = UUID()
  // 2
  var title: String
  var videos: [Video]
  
  init(title: String, videos: [Video]) {
    self.title = title
    self.videos = videos
  }
  
  func hash(into hasher: inout Hasher) {
    hasher.combine(id)
  }
  
  static func == (lhs: Section, rhs: Section) -> Bool {
    lhs.id == rhs.id
  }
}

你是这样做的:

  • 1) 与Video类一样,您也可以将Section遵循Hashable
  • 2) Section有两个重要的属性,稍后您将使用它们对视频进行分类:title and videos

接下来,您需要创建一些sections。打开Video.swift,向下滚动。您将注意到该类上的一个扩展,它具有一个名为allVideos的静态属性。这个数组存储应用程序显示的所有视频videos

现在您正在介绍多个sections,这个属性还不够。删除// MARK: - Video Sample Data下的所有代码。

这会在你使用Video.allVideos的地方引起问题。不过现在先不用担心。你马上就能修好。

接下来,打开Section.swift。然后粘贴以下代码在文件的底部:

extension Section {
  static var allSections: [Section] = [
    Section(title: "SwiftUI", videos: [
      Video(
        title: "SwiftUI",
        thumbnail: UIImage(named: "swiftui"),
        lessonCount: 37,
        link: URL(string: "https://www.raywenderlich.com/4001741-swiftui")
      )
    ]),
    Section(title: "UIKit", videos: [
      Video(
        title: "Demystifying Views in iOS",
        thumbnail: UIImage(named: "views"),
        lessonCount: 26,
        link:
        URL(string:
          "https://www.raywenderlich.com/4518-demystifying-views-in-ios")
      ),
      Video(
        title: "Reproducing Popular iOS Controls",
        thumbnail: UIImage(named: "controls"),
        lessonCount: 31,
        link: URL(string: """
          https://www.raywenderlich.com/5298-reproducing
          -popular-ios-controls
          """)
      )
    ]),
    Section(title: "Frameworks", videos: [
      Video(
        title: "Fastlane for iOS",
        thumbnail: UIImage(named: "fastlane"),
        lessonCount: 44,
        link: URL(string:
          "https://www.raywenderlich.com/1259223-fastlane-for-ios")
      ),
      Video(
        title: "Beginning RxSwift",
        thumbnail: UIImage(named: "rxswift"),
        lessonCount: 39,
        link: URL(string:
          "https://www.raywenderlich.com/4743-beginning-rxswift")
      )
    ]),
    Section(title: "Miscellaneous", videos: [
      Video(
        title: "Data Structures & Algorithms in Swift",
        thumbnail: UIImage(named: "datastructures"),
        lessonCount: 29,
        link: URL(string: """
          https://www.raywenderlich.com/977854-data-structures
          -algorithms-in-swift
        """)
      ),
      Video(
        title: "Beginning ARKit",
        thumbnail: UIImage(named: "arkit"),
        lessonCount: 46,
        link: URL(string:
          "https://www.raywenderlich.com/737368-beginning-arkit")
      ),
      Video(
        title: "Machine Learning in iOS",
        thumbnail: UIImage(named: "machinelearning"),
        lessonCount: 15,
        link: URL(string: """
          https://www.raywenderlich.com/1320561-machine-learning-in-ios
        """)
      ),
      Video(
        title: "Push Notifications",
        thumbnail: UIImage(named: "notifications"),
        lessonCount: 33,
        link: URL(string:
          "https://www.raywenderlich.com/1258151-push-notifications")
      ),
    ])
  ]
}

很多代码。在这里,您创建了一个静态属性allSections,其中有四个sections,每个部分有一个或多个视频。这基本上只是虚拟的数据——在一个完全成熟的应用程序中,你可以从服务器获取这些信息。

有了这个,你现在可以使用Section.allSections属性访问应用程序的Section


Adopting the New Section Class

回到VideosViewController.swift

替换:

private var videoList = Video.allVideos

使用

private var sections = Section.allSections

接下来,您需要更新applySnapshot(animatingDifferences:)来处理Section

applySnapshot(animatingDifferences:)中替换以下代码:

snapshot.appendSections([.main])
snapshot.appendItems(videos)

使用

snapshot.appendSections(sections)
sections.forEach { section in
  snapshot.appendItems(section.videos, toSection: section)
}

这里有两个变化。首先,将sections数组附加到快照。其次,对每个section进行循环,并将其items (videos)添加到snapshot中。

您还可以显式地指定每个视频的section,因为既然有多个sections,数据源就不能正确地推断出item’s section


Fixing Search, Again

现在应用程序中已经有了sections,您需要再次修复搜索功能。这是最后一次了,我保证。前面处理搜索查询的方法返回了一个视频数组,因此需要编写一个返回section数组的新方法。

用以下方法替换filteredVideos(for:)

func filteredSections(for queryOrNil: String?) -> [Section] {
  let sections = Section.allSections

  guard 
    let query = queryOrNil, 
    !query.isEmpty 
    else {
      return sections
  }
    
  return sections.filter { section in
    var matches = section.title.lowercased().contains(query.lowercased())
    for video in section.videos {
      if video.title.lowercased().contains(query.lowercased()) {
        matches = true
        break
      }
    }
    return matches
  }
}

这个新的过滤器返回所有的sections,其名称匹配搜索条件加上那些包含的视频,其标题匹配搜索。

updateSearchResults(:)内部,替换:

videoList = filteredVideos(for: searchController.searchBar.text)

使用

sections = filteredSections(for: searchController.searchBar.text)

这将关闭刚刚实现的section-based的新版本的搜索筛选器。

那真是一次冒险。

你可以在iPad上看sections更清楚,所以使用iPad模拟器。构建和运行。

太棒了!视频被分类!但是,没有一个简单的方法可以看出一个视频属于哪一类。


Supplementary Views

要将header添加到sections中,您需要实现一个supplementary header view。不要担心,因为这并不像听起来那么复杂。

首先,创建一个名为SectionHeaderReusableView.swift的新文件。添加以下代码到文件:

import UIKit

// 1
class SectionHeaderReusableView: UICollectionReusableView {
  static var reuseIdentifier: String {
    return String(describing: SectionHeaderReusableView.self)
  }

  // 2
  lazy var titleLabel: UILabel = {
    let label = UILabel()
    label.translatesAutoresizingMaskIntoConstraints = false
    label.font = UIFont.systemFont(
      ofSize: UIFont.preferredFont(forTextStyle: .title1).pointSize,
      weight: .bold)
    label.adjustsFontForContentSizeCategory = true
    label.textColor = .label
    label.textAlignment = .left
    label.numberOfLines = 1
    label.setContentCompressionResistancePriority(
      .defaultHigh, 
      for: .horizontal)
    return label
  }()
  
  override init(frame: CGRect) {
    super.init(frame: frame)
    // 3
    backgroundColor = .systemBackground
    addSubview(titleLabel)

    if UIDevice.current.userInterfaceIdiom == .pad {
      NSLayoutConstraint.activate([
        titleLabel.leadingAnchor.constraint(
          equalTo: leadingAnchor, 
          constant: 5),
        titleLabel.trailingAnchor.constraint(
          lessThanOrEqualTo: trailingAnchor, 
          constant: -5)])
    } else {
      NSLayoutConstraint.activate([
        titleLabel.leadingAnchor.constraint(
          equalTo: readableContentGuide.leadingAnchor),
        titleLabel.trailingAnchor.constraint(
          lessThanOrEqualTo: readableContentGuide.trailingAnchor)
      ])
    }
    NSLayoutConstraint.activate([
      titleLabel.topAnchor.constraint(
        equalTo: topAnchor, 
        constant: 10),
      titleLabel.bottomAnchor.constraint(
        equalTo: bottomAnchor, 
        constant: -10)
    ])
  }
  
  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}

这是相当大的代码块,但没有太多。简而言之,视图有一个label,用于显示section的标题。检查代码:

  • 1) 你添加一个类,让它成为UICollectionReusableView的子类。这意味着section header视图可以像单元格一样重用。
  • 2) 设置标题label的样式
  • 3) 在初始化时,将title label添加到header view并设置其Auto Layout约束。取决于你是否在iPad上使用不同的规则。

打开VideosViewController.swift。在// MARK: - Layout Handling下面,configureLayout()的开头添加以下代码:

collectionView.register(
  SectionHeaderReusableView.self, 
  forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, 
  withReuseIdentifier: SectionHeaderReusableView.reuseIdentifier
)

这将注册您刚刚使用collection view编写的header view,因此您可以使用section headers

接下来,在相同的方法中,在sectionProvider闭包和return section之前添加以下代码:

// Supplementary header view setup
let headerFooterSize = NSCollectionLayoutSize(
  widthDimension: .fractionalWidth(1.0), 
  heightDimension: .estimated(20)
)
let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
  layoutSize: headerFooterSize, 
  elementKind: UICollectionView.elementKindSectionHeader, 
  alignment: .top
)
section.boundarySupplementaryItems = [sectionHeader]

这段代码告诉布局系统,您希望为每个section显示一个header

你几乎完成了!在makeDataSource()中,在return dataSource之前添加以下代码:

// 1
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
  // 2
  guard kind == UICollectionView.elementKindSectionHeader else {
    return nil
  }
  // 3
  let view = collectionView.dequeueReusableSupplementaryView(
    ofKind: kind,
    withReuseIdentifier: SectionHeaderReusableView.reuseIdentifier,
    for: indexPath) as? SectionHeaderReusableView
  // 4
  let section = self.dataSource.snapshot()
    .sectionIdentifiers[indexPath.section]
  view?.titleLabel.text = section.title
  return view
}

在这里你:

  • 1) 获取supplementary view section的一个实例。
  • 2) 确保supplementary view provider请求一个header
  • 3) dequeue一个新的header view
  • 4) 从数据源检索该section,然后将titleLabel的文本值设置为该section的标题。

构建和运行。

下面是这款应用在iPad上的样子:

成功了!

如果需要挑战,请尝试基于数据源返回的item引入不同的collection view cells

后记

本篇主要讲述了UICollectionView UICollectionViewDiffableDataSource的使用,感兴趣的给个赞或者关注~~~

你可能感兴趣的:(UIKit框架(三十六) —— UICollectionView UICollectionViewDiffableDataSource的使用(一))