垂直分页无限制

大多数移动应用程序用户将滚动视为理所当然。 当他们不顾一切地使用指尖在Facebook,Instagram,Twitter,LinkedIn,Google报亭或许多其他应用程序上的新闻源中无休止地滚动时,他们不会停止思考精心构建的代码,以支持看似轻松的滚动。

实际上,创建无缝滚动动作需要进行大量工作,以提供定制的,简化的 用户体验 在Distillery,我们面临着一个艰巨的挑战,因此当我们意识到没有现成的解决方案可以支持 我们正在构建 的iOS Soapbox 应用 所需的滚动体验时,我们就准备好并愿意 这篇博客重述了我们如何应对挑战。 该文章由Distillery的开发商之一Nikolay Sohryakov撰写,于2016年12月首次发表在Distillery的博客上。

是否想了解我们更多的经验,为我们为初创企业和企业客户开发的应用和网站开发定制的高质量UX解决方案? 请查看我们的 特色应用程序开发工作 或有关 使用PubNub建立传达情感的交谈聊天, 为Roommates建立Circle 使用SignalR加快客户端/服务器通信 为nēdl应用程序实现Alexa以及在应用程序中 使用Kotlin 的博客- 建设过程

适用于iOS的Soapbox应用程序提供了一种有趣,简单的方式来捕捉时刻,与世界分享时刻并帮助宣传事物和原因。 Soapbox也强调了通过社交渠道获利的能力。 您可以跟随您的朋友,发现来自世界各地的杰出创作者,他们分享您喜爱的惊人内容。

但是,在开发应用程序时,Distillery遇到了许多技术挑战,我们需要克服这些挑战才能使应用程序按预期的方式工作。 在本文中,我们将特别讨论一个挑战。

Soapbox是围绕新闻源构建的以内容为中心的应用程序。 但是,传统新闻提要实现的某些功能与我们希望用户拥有的体验不太吻合:

  1. 在Facebook之类的应用中实现的新闻源可提供连续的滚动体验,但是我们希望用户能够向上或向下滑动以直接捕捉到源中的下一个(或上一个)帖子。
  2. 我们希望一次只在屏幕上显示一个新闻提要帖子,即使该帖子未填充整个屏幕。
  3. 我们还希望帖子长度不限,因此帖子实际上可能会延伸到单个屏幕之外(并且您可以向下滚动以通过拖动查看更多内容)。
  4. 我们希望用户能够帖子之间滑动,以便像卡片一样帖子之间移动。

那么,问题是如何实现这些用户体验目标。

解决这些问题的第一个想法是使用UITableView并将isPagingEnabled设置为true 。 但这被证明不是可行的选择,因为UITableView要求页面大小相同。 我们在网上冲浪以寻找现成的解决方案,却一无所获-因此,我们决定构建自己的控件,该控件可以完全自定义并满足我们的所有需求。 该控件我们称为CardScrollView

因为我们希望Soapbox具有滚动内容提要,所以我们决定使用UIScrollView作为新控件的基础。 UIScrollView实际上实现了基础的滚动逻辑,并公开了控制滚动过程所需的所有API。 但是UIScrollView内部的内容呢? UITableView吸引人的一件事是它自己管理内存并在重用单元格方面做得很好。 那么我们如何为CardScrollView做到这一点呢?

我们回想起在2010年Apple全球开发者大会上进行的对话。 在那里,苹果工程师讨论了使用滚动视图设计应用程序。 本演讲的一个非常重要的部分介绍了如何通过对象池模式的实现重用 UIScrollView中的视图。 就是这样! 那就是我们为CardScrollView采取的方法

为了使CardScrollView可重用,我们需要将其设置为框架。 这就需要创建一个基于Cocoa Touch Framework的新Xcode项目。

通过首先进行设置,我们将获得一个现成的框架二进制文件。 然后,我们开始编写一些代码!

对于CardScrollView控件,我们需要两个类:

  • 代表单元格的类(我们称其为卡)
  • 滚动视图本身(我们称为CardScrollView)

实现单元类非常简单,因此在这里,我们将重点介绍如何实现第二个类CardScrollView。

构建CardScrollView类

首先,我们需要实现卡的布局逻辑并使它们全部协同工作。 我们需要保留一组可重用的视图,每个视图代表一张卡。 为了使对不同种类的数据使用不同的视图成为可能,我们引入了一个重用标识符,其作用与UITableView.1中的重用标识符相同。

public func dequeueCard(withIdentifier reuseIdentifier: String) -> CardScrollViewCard { 
guard self.reuseIdentifiers[reuseIdentifier] != nil else { fatalError("Identifier \(reuseIdentifier) is not registered") }
guard let reusedcard = self.reuseCard(reuseIdentifier: reuseIdentifier) else {
let cardObject = self.reuseIdentifiers[reuseIdentifier]
switch cardObject {
case let nib as UINib:
guard let unwrappedView = nib.instantiate(withOwner: nil, options: nil).first as? CardScrollViewCard else { fatalError("Expected to receive \(String(describing: CardScrollViewCard.self)) but got something else") }
return unwrappedView
case let classObject as CardScrollViewCard.Type:
return classObject.init(reuseIdentifier: reuseIdentifier)
default:
fatalError("card reuse identifier is not registered")
}
}
return reusedcard
}
public func dequeueCard(withIdentifier reuseIdentifier: String) -> CardScrollViewCard {
guard self.reuseIdentifiers[reuseIdentifier] != nil else { fatalError("Identifier \(reuseIdentifier) is not registered") }
guard let reusedcard = self.reuseCard(reuseIdentifier: reuseIdentifier) else {
let cardObject = self.reuseIdentifiers[reuseIdentifier]
switch cardObject {
case let nib as UINib:
guard let unwrappedView = nib.instantiate(withOwner: nil, options: nil).first as? CardScrollViewCard else { fatalError("Expected to receive \(String(describing: CardScrollViewCard.self)) but got something else") }
return unwrappedView
case let classObject as CardScrollViewCard.Type:
return classObject.init(reuseIdentifier: reuseIdentifier)
default:
fatalError("card reuse identifier is not registered")
}
}
return reusedcard
}
fileprivate func reuseCard(reuseIdentifier: String) -> CardScrollViewCard? {
guard self.reuseIdentifiers[reuseIdentifier] != nil else { fatalError("Identifier \(reuseIdentifier) is not registered") }
var resultingcard: CardScrollViewCard? = nil
let availablecards = self.reusePool.filter { $0.reuseIdentifier == reuseIdentifier }
if availablecards.count > 0 {
resultingcard = availablecards.first
let index = self.reusePool.index { $0 == resultingcard } guard let unwrappedIndex = index else { fatalError("Internal inconsistency error") }
self.reusePool.remove(at: unwrappedIndex)
}
resultingcard?.prepareForReuse(willCollapse: self.collapsecardsWhenHidden)
return resultingcard
}
fileprivate func reuseCard(reuseIdentifier: String) -> CardScrollViewCard? {
guard self.reuseIdentifiers[reuseIdentifier] != nil else { fatalError("Identifier \(reuseIdentifier) is not registered") }
var resultingcard: CardScrollViewCard? = nil
let availablecards = self.reusePool.filter { $0.reuseIdentifier == reuseIdentifier }
if availablecards.count > 0 {
resultingcard = availablecards.first
let index = self.reusePool.index { $0 == resultingcard } guard let unwrappedIndex = index else { fatalError("Internal inconsistency error") }
self.reusePool.remove(at: unwrappedIndex)
}
resultingcard?.prepareForReuse(willCollapse: self.collapsecardsWhenHidden)
return resultingcard
}

在此实现中, reuseIdentifiers是将重用标识符与表示对象匹配的字典,并且reusePool被定义为数组。
为了更好地理解它的工作方式,让我们看一下下面两个方法的实现:

 public func register(class aClass: CardScrollViewCard.Type, forReuseIdentifier reuseIdentifier: String) { 
guard self.reuseIdentifiers[reuseIdentifier] == nil else { fatalError("Reuse Identifier \(reuseIdentifier) is already registered for \(self.reuseIdentifiers[reuseIdentifier])") }
self.reuseIdentifiers[reuseIdentifier] = aClass
}
public func register(class aClass: CardScrollViewCard.Type, forReuseIdentifier reuseIdentifier: String) {
guard self.reuseIdentifiers[reuseIdentifier] == nil else { fatalError("Reuse Identifier \(reuseIdentifier) is already registered for \(self.reuseIdentifiers[reuseIdentifier])") }
self.reuseIdentifiers[reuseIdentifier] = aClass
}
public func register(nib: UINib, forReuseIdentifier reuseIdentifier: String) {
guard self.reuseIdentifiers[reuseIdentifier] == nil else { fatalError("Reuse Identifier \(reuseIdentifier) is already registered for \(self.reuseIdentifiers[reuseIdentifier])") }
self.reuseIdentifiers[reuseIdentifier] = nib
}
public func register(nib: UINib, forReuseIdentifier reuseIdentifier: String) {
guard self.reuseIdentifiers[reuseIdentifier] == nil else { fatalError("Reuse Identifier \(reuseIdentifier) is already registered for \(self.reuseIdentifiers[reuseIdentifier])") }
self.reuseIdentifiers[reuseIdentifier] = nib
}

如您所见,将NIB对象或类类型注册为重用标识符时,我们只需将其放入字典中以备将来使用。 而已!

此实现假定reusePool中的任何视图都可重用,并且当前正在用于显示卡。

收集和缓存卡数据

现在,让我们看一下如何将视图添加到重用池中以及如何管理可见性状态。

要在滚动视图中管理卡片的布局,我们需要提前知道它们的高度和位置。 因此,我们构建了一种收集此信息并将其缓存以备将来使用的方法:

 fileprivate func setUpHeightAndOffsetData() { 
var currentOffset: Float = 0.0
let numberOfCards = self.cardScrollViewDataSource?.numberOfCards() ?? 0
var cardsDetails: [CardDetails] = []
for i in 0..<numberOfCards {
var estimatedCardHeight = self.cardScrollViewDelegate?.cardCollection(cardsCollection: self, estimatedHeightForCardAtIndex: i) ?? 0
if estimatedCardHeight < self.minimalCardHeight {
estimatedCardHeight = self.minimalCardHeight
}
cardsDetails.append(CardDetails(startPositionY: currentOffset, height: estimatedCardHeight))
currentOffset += estimatedCardHeight
}

self.cardsDetails = cardsDetails
self.contentSize = CGSize(width: 0, height: CGFloat(currentOffset))
}

这段代码构建了一个CardDetails实例数组, 这些实例捕获了执行正确布局所需的参数。 CardDetails是一个结构,用于记录卡的起始位置,其高度以及在可见时显示的指针。 如代码所示,我们将卡片的最小高度限制为滚动视图的高度,因此我们的卡片将始终具有等于或大于滚动视图高度的高度。

布置卡片

将所有必需的数据收集到一个阵列中后,布置卡的过程就变得非常简单。 对于每个可见(或即将变得可见)的卡片,我们计算其Y坐标并将其作为子视图添加到滚动视图。 布置好卡片后,下一步就是回收那些不再可见的卡片:

 fileprivate func layoutCards() { 
let currentStartY: Float = Float(self.contentOffset.y)
let currentEndY: Float = currentStartY + Float(self.bounds.size.height)
guard var cardIndexToDisplay = self.cardIndex(yOffset: currentStartY, inRange: 0..<self.cardsDetails.count) else {
//nothing to layout return
}
var newVisibleCards: Set = Set() let xOrigin: Float = 0
var yOrigin: Float = 0 var cardHeight: Float = 0
repeat {
guard let card = self.prepareCard(atIndex: cardIndexToDisplay) else { fatalError("Could not get a card") }
newVisibleCards.insert(cardIndexToDisplay)
self.cardsDetails[cardIndexToDisplay].cachedard = card
yOrigin = self.cardsDetails[cardIndexToDisplay].startPositionY
cardHeight = self.cardsDetails[cardIndexToDisplay].height
card.frame = CGRect(x: CGFloat(xOrigin), y: CGFloat(yOrigin), width: CGFloat(self.bounds.size.width), height: CGFloat(cardHeight))
self.addSubview(card)
cardIndexToDisplay += 1
} while yOrigin + cardHeight < currentEndY && cardIndexToDisplay < self.cardsDetails.count
self.recycleNotVisibleCards(withVisibleCardsIndexes: newVisibleCards)
}

关于此方法,有一件重要的事情要牢记:它会被称为很多 -每次滚动内容,每次旋转设备或发生类似操作时。 为了实现所需的响应速度,我们为滚动视图的contentOffset属性实现了didSet观察器:

 override open var contentOffset: CGPoint { 
didSet {
self.layoutCards()
}
}

这就是我们需要做的所有工作,以确保正确的卡布局和重复使用!

处理滚动

但是滚动逻辑呢?

CardScrollView继承自UIScrollView 。 因此,要获得对滚动过程的控制,我们需要将self设置为UIScrollViewDelegate并实现以下委托方法:

  • scrollViewWillBeginDragging —跟踪拖动过程的开始
  • ScrollViewWillEndDragging —设置正确的目标内容偏移量
  • scrollViewDidEndDragging —调整内容偏移量,以防用户尝试拖放动作而不是滑动
  • scrollViewDidEndDecelerating —处理极端情况

scrollViewWillBeginDraggingscrollViewDidEndDecelerating方法负责跟踪内容偏移并将其保留为内部变量。 设置滚动偏移量本身的工作主要由scrollViewWillEndDragging方法完成。

要发挥所有作用,我们需要做的第一件事就是确定滚动方向。 很容易发现我们是否有两个不同的偏移量-目标内容偏移量(我们称为targetContentOffset)和当前偏移量(我们称为lastContentOffset)。 通过一个减去另一个,我们得到一个delta值,我们称它为scrollDelta:

 let scrollDelta = Float(targetContentOffset.pointee.y - self.lastContentOffset.y) 
let scrollDirection = scrollDelta > 0 ? ScrollDirection.down : ScrollDirection.up

实际上,这提供了我们计算下一步将可见的单元格索引所需的所有数据。 如果scrollDelta值大于零,则将关注下一个单元格; 如果小于零,则前一个单元格将被聚焦。

最后,还有一个考虑因素:如果卡的高度大于允许的最小值(即CardScrollView的高度),那么我们需要根据滚动方向将其附加到其顶部或底部边缘。 这可以通过更改高度差来轻松实现:

 if abs(scrollDelta) > self.scrollThreshold { 
switch scrollDirection {
case .down:
cardIndexToFocus = secondCardIndex
case .up:
cardIndexToFocus = firstCardIndex
offsetAdjustent = Float(abs(CGFloat(self.cardsDetails[cardIndexToFocus].height) - scrollView.bounds.height))
}
}
else {
switch scrollDirection {
case .down:
cardIndexToFocus = firstCardIndex
offsetAdjustent = Float(abs(CGFloat(self.cardsDetails[cardIndexToFocus].height) - scrollView.bounds.height))
case .up:
cardIndexToFocus = secondCardIndex
}
}
targetContentOffset.pointee.y = CGFloat(self.cardsDetails[cardIndexToFocus].startPositionY + offsetAdjustent)

通过测试以查看给定卡片是否长于卡片高度,此代码位确保向下滑动手势实际上向下滚动到卡片内容的下一部分,而不是跳到下一张卡片。 一旦用户到达卡片的底部,向下滑动即可跳至下一张卡片。 相反,从长卡的底部向上滑动会在卡中向后滚动,然后跳到上一张卡。

处理边缘盒

此代码将涵盖90%的滚动情况。 剩下的10%是边缘情况,需要特别注意:

  1. 用户拖动而不是向上/向下滑动。 在这种情况下,将没有减速动画,并且上面的代码将无法执行其工作,因为目标内容偏移量与最后一个内容偏移量相同,并且滚动增量将等于0。
  2. 用大卡片滚动内容时。 在这种情况下,很难计算屏幕上将显示哪些卡以及如何调整它们。

为了处理第一种边缘情况,我们需要在用户完成拖动后执行布局计算(仅需要使用scrollViewDidEndDragging方法)。 要处理第二种边缘情况,我们需要在scrollViewDidEndDecelerating中执行相同的操作 。 这种方法使滚动动画更流畅,并提供“实时”体验。

CardScrollView框架的功能比我们在此显示的要多,但这是其本质。 我们还没有显示的大部分是家政服务。 您可以在GitHub上找到完整的源代码以及完整记录的代码和用法示例。 该框架支持CocoaPods和Carthage。

CardScrollView框架的开发正在进行中,因此,如果您有任何疑问或建议,请随时与我们联系-或,如果有什么好介绍的话,请在GitHub上创建Pull Request!

对一款出色的新应用有任何想法? 与Distillery联系,以将其变为现实。

请参阅以下文章:

  • 使用滚动视图设计应用
  • 建立现代框架
  • 如何建立一个UITableView

参考文献

1.↑ 注意 :为了使控件易于使用并且易于使用,我们尝试使命名约定与Apple对于 UITableView的 约定非常接近 因此,许多方法都以类似的方式调用。