03 10月

使用Swift语言自定义Pull To Refresh控件

文 / gabriel theodoropoulos:拥有20年的编程经验,2010年下半年以来,一直专注于iOS开发,长期使用多种语言在不同平台实现软件架构解决方案。

原文链接: http://www.appcoda.com/custom-pull-to-refresh/

全世界有非常多的应用程序。要开发出新的应用,并且能够吸引用户的注意力,使用应用能够脱颖而出。毫无疑问,有人会说使简单应用出类拔萃的秘笈是开发小组(开发人员和设计人员)所使用的个性化技艺,用在大多数开发人员不做处理的小细节处。其中之一就是从本文题目中可见一斑的pull-to-refresh控件。通过本文我会展示使该控件改头换面的方法。

你知道,pull-to-refresh控件是活动指示器(经常带有短小的信息),数据加载过程中,会出现在表视图的顶部。此时的表视图还未完成刷新。实际上,pull-to-refresh控件与“Please, wait…”信息提示类似,当用户等待获取和显示新的内容时出现。于此最知名的应用就是电子邮箱。向底部拖拽邮件视图,邮件列表会得到刷新。该控件自iOS 6.0引入,之后在不计其数的应用软件中频繁地被使用。

pull-to-refresh-featured-1024x533

如果想要在应用中使用pull-to-refresh控件的话,就要查找如何去用的相关信息,也一定会找到simon的文章,文章很好地诠释了你想知道的一切。而本文要说说pull-to-refresh控件的另外一面,那就是如何自定义一个pull-to-refresh控件。这样一来,你就能够在这个不起眼儿但比较重要的细节上添加不同的风格,给应用赋予不同的视角。

长话短说,接下来看看添加自定义内容和动画的技巧,来使用任何你想要添加的对象“取代”默认的pull-to-refresh控件。要注意,下面的内容是展示你要跟着做的步骤,实际的自定义内容完全由你自己决定,更确切地说,由你的想象力决定。开始吧,用不了多久,就能创建出自定义的pull-to-refresh控件了!

示例应用概览

下面的动画就是本文中自定义的pull-to-refresh控件:

t38_1_final_sample

可以看到,表视图里面有一些模拟数据。我们的目的不是真地要从服务器获取数据,而是关注刷新的过程。活动指示器是不会出现的,取而代之的是与刷新过程持续时长相同的“APPCODA”动画。

想要知道模拟刷新的动作什么时候结束的话,我只能告诉你这里使用了计时器(NSTimer)对象。四秒钟后,该对象会结束刷新动作,将自定义的pull-to-refresh控件隐藏起来。示例中的四秒钟是随机选取的时间间隔,这当然是为了在本文中演示自定义控件的方法。很多情况下数据刷新时间都比这要短(特别是有高速英特网连接的情况下)。因此,在真实的应用中不要为了给用户头脑中留下印记而使用计时器来显示自定义的pull-to-refresh控件。要知道,特别是如果应用很棒,被用户经常使用的情况下,用户会很多机会看到这个控件。

正如你所看到的,要开发一个极其简单的项目。但没有必要从头开始,和往常一样,可以先下载一个初始项目。这是在故事板(storyboard)中所作的界面设计,还有一个Interface Builder文件,叫做RefreshContents.xib。在界面中我加入了自定义的内容,用来取代普通pull-to-refresh控件来显示的内容。实际上,该自定义控件包含了七个标签(UILabel)对象。在一起组成了“APPCODA”字样。还有一个自定义视图(UIView)作为这些标签的容器。所以必要对字体格式都做好了,正确设置了约束。随后要做的就是在视图控制器中加载这些控件,并用适当的方式处理它们。

所以,先下载初始项目,用Xcode打开它。

默认的Pull-To-Refresh控件

对示例应用要做的第一件事就是显示表视图中的模拟数据。下载的初始项目中已经包含了叫做tblDemo的IBOutlet属性,用来连接故事板中的表视图。因此需要编写表视图所需的代理(delegate)方法和数据源(datasource)方法。但是,这样做之前需要指定表视图中要显示的数据。在ViewController.swift文件中,在类内添加下列代码行:

  1. var dataArray: Array<String> = [“One”“Two”“Three”“Four”“Five”]

现在,如下所示,通过添加UITableViewDelegate和UITableViewDataSource协议修改类的第一行:

  1. class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource

然后要将ViewController的类实例作为表视图的代理和数据源:

  1. override func viewDidLoad() {
  2.     …
  3.     tblDemo.delegate = self
  4.     tblDemo.dataSource = self
  5. }

现在添加表视图的方法,用来显示模拟数据:

  1. func numberOfSectionsInTableView(tableView: UITableView) -> Int {
  2.     return 1
  3. }
  4. func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
  5.     return dataArray.count
  6. }
  7. func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
  8.     let cell = tableView.dequeueReusableCellWithIdentifier(“idCell”, forIndexPath: indexPath) as! UITableViewCell
  9.     cell.textLabel!.text = dataArray[indexPath.row]
  10.     return cell
  11. }
  12. func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
  13.     return 60.0
  14. }

好啦!这些都没有啥难度,运行应用后就会看到表视图显示“One, Two, …”。

t38_2_tableview_data

来看看如何显示和使用默认情况下的pull-to-refresh控件吧。我们现在这种情况是ViewController或其他什么类的子类,总之不是UITable ViewController的子类。pull-to-refresh控件必须作为表视图的子类添加进来(参见Simon的相关文章,作为UITableViewController的子类添加进来的做法)。首先在类的开始处声明refreshControl。

  1. var refreshControl: UIRefreshControl!

别忘了,尽管refreshControl由一组特别的控件组成,但声明和使用的方法与其他属性和对象别无两样。所以上面的做法很正常。

首先在viewDidLoad方法中初始化refreshControl,然后将它添加到表视图当中。

  1. override func viewDidLoad() {
  2.     …
  3.     refreshControl = UIRefreshControl()
  4.     tblDemo.addSubview(refreshControl)
  5. }

再一次运行应用,当表视图被拖拽到底部的时候,你会发现旋转的图标出现了。但不要指望这个控件再隐藏回去,这个功能不是自动产生的。必须要明确地结束刷新动作,但这是稍后要做的事情。目前的亮点是刷新指示运行良好。

t38_3_default_refresh

提示一下,这个带有刷新功能的控件,其背景色和控件颜色都可以修改。例如,下面的两行代码运行后的效果就是底色是红色,旋转图标是黄色。

  1. override func viewDidLoad() {
  2.     …
  3.     refreshControl = UIRefreshControl()
  4.     refreshControl.backgroundColor = UIColor.redColor()
  5.     refreshControl.tintColor = UIColor.yellowColor()
  6.     tblDemo.addSubview(refreshControl)
  7. }

t38_4_red_yellow_refresh

自定义控件的内容

自定义pull-to-refresh控件背后的关键点是给控件本身添加自己想要的任何额外内容,所添加的内容将作为子视图。示例程序中所谓的额外内容就是RefreshContents.xib文件。更具体地说,Interface Builder文件的内容是这个样子的:

t38_5_refresh_contents

如你所见,视图对象包含了依次七个标签。每个标签与“APPCODA”字母相对应。

接下来要做的事情非常简单:通过编程的方式将.xib文件的内容赋值给属性。更确切地说,将视图对象复制给UIView属性,所有标签都会被依次放到一个数组当中。这样做就可以把这些视图做成任何我们想要的动画效果。

现在深入到细节。首先,在类的开始处添加下列两行声明语句:

  1. var customView: UIView!
  2. var labelsArray: Array<UILabel> = []

有了上面新声明的两个属性,我们来创建一个新的自定义方法,用来加载.xib文件所有的内容:

  1. func loadCustomRefreshContents() {
  2.     let refreshContents = NSBundle.mainBundle().loadNibNamed(“RefreshContents”, owner: self, options: nil)
  3. }

我们会继续改进上面的自定义函数。下一步要做的是给customView分配上面代码加载的视图对象。注意,像上面的这种方法,从一个外部.xib文件中获取子视图,获得的是包含所有这些子视图的数组。在这里,数组只包含自定义的视图对象,也就是作为自定义视图子视图的那些标签,而不是.xib中自定义视图之外单独存在的视图对象。还要注意下面的几行代码,使自定义视图的框架与带有刷新功能旧有控件的框架要相吻合。

  1. func loadCustomRefreshContents() {
  2.     …
  3.     customView = refreshContents[0] as! UIView
  4.     customView.frame = refreshControl.bounds
  5. }

拖动表视图后刷新动作被触发,上面代码中的最后一行使自定义控件尺寸的变化与已知的约束相一致。

现在将所有的标签加载到labelsArray数组中。也许你已经注意到了,RefreshContents.xib文件中的每个标签都被分配了一个标号。从左侧开始,标号范围从1到7。我们将会利用这些标号单独访问每个标签。

  1. func loadCustomRefreshContents() {
  2.     …
  3.     for var i=0; i<customView.subviews.count; ++i {
  4.         labelsArray.append(customView.viewWithTag(i + 1) as! UILabel)
  5.     }
  6. }

最后,把自定义视图作为刷新控件的子视图添加进来:

  1. func loadCustomRefreshContents() {
  2.     …
  3.     refreshControl.addSubview(customView)
  4. }

搞定!还有一件事就是调用上面的函数,当然是在viewDidLoad方法中调用:

  1. override func viewDidLoad() {
  2.     …
  3.     loadCustomRefreshContents()
  4. }

最后要做一些重要的且必须要做的修改。在viewDidLoad方法中,将刷新控件的背景色和控件颜色设置成透明色。下面是修改后最终的代码。

  1. override func viewDidLoad() {
  2.     super.viewDidLoad()
  3.     // Do any additional setup after loading the view, typically from a nib.
  4.     tblDemo.delegate = self
  5.     tblDemo.dataSource = self
  6.     refreshControl = UIRefreshControl()
  7.     refreshControl.backgroundColor = UIColor.clearColor()
  8.     refreshControl.tintColor = UIColor.clearColor()
  9.     tblDemo.addSubview(refreshControl)
  10.     loadCustomRefreshContents()
  11. }

测试一下应用,拉动刷新时,可以看到带有标签的自定义视图取代了默认情况下的图标。当然还没有动画效果,下面我们就要实现动画效果。

t38_6_custom_contents

初始化自定义动画

这就是最终要实现的动画效果:

t38_7_refresh_animation

如果仔细观察,你会发现整个动画过程由两个子过程组成:

  • 从第一个开始,每个标签略微旋转(45度),与此同时,标签文本的颜色发生变化。下一个标签开始旋转前,当前旋转的标签状态复原。
  • 所有标签旋转过程完成后恢复原状,然后一起按比例放大,再按比例缩小。

我们把每个部分的动画作为单独的自定义函数来实现,尽量保持简单易懂。动手之前,要声明一些随后要用到的新属性。

  1. var isAnimating = false
  2. var currentColorIndex = 0
  3. var currentLabelIndex = 0

下面对每个属性做简单的介绍:

  • isAnimating标志动画过程是否发生。使用该属性用来告知是否能够开始一个新的动画过程(很明显,当一个动画开始后,我们不希望开始第二个动画)。
  • currentColorIndex属性会被用在实现的另一个自定义函数中。该函数拥有一个表示颜色(控件文本的颜色)的数组,这个属性表示下一个会被用到的标签文本颜色。
  • currentLabelIndex属性代表动画效果的第一个子过程里标签的索引。这样不仅可以确定下一个要旋转和着色的标签,还可以确定动画效果的第二个子过程(按比例放大)何时应该开始。

现在来处理动画效果的第一部分。使用一个叫做animateRefreshStep1()的函数完全实现了该功能:

  1. func animateRefreshStep1() {
  2.     isAnimating = true
  3.     UIView.animateWithDuration(0.1, delay: 0.0, options: UIViewAnimationOptions.CurveLinear, animations: { () -> Void in
  4.         self.labelsArray[self.currentLabelIndex].transform = CGAffineTransformMakeRotation(CGFloat(M_PI_4))
  5.         self.labelsArray[self.currentLabelIndex].textColor = self.getNextColor()
  6.         }, completion: { (finished) -> Void in
  7.             UIView.animateWithDuration(0.05, delay: 0.0, options: UIViewAnimationOptions.CurveLinear, animations: { () -> Void in
  8.                 self.labelsArray[self.currentLabelIndex].transform = CGAffineTransformIdentity
  9.                 self.labelsArray[self.currentLabelIndex].textColor = UIColor.blackColor()
  10.                 }, completion: { (finished) -> Void in
  11.                     ++self.currentLabelIndex
  12.                     if self.currentLabelIndex < self.labelsArray.count {
  13.                         self.animateRefreshStep1()
  14.                     }
  15.                     else {
  16.                         self.animateRefreshStep2()
  17.                     }
  18.             })
  19.     })
  20. }

我们来说说上面这段代码的核心部分。一开始,isAnimating标志被置为true,因此认定没有要开始的动画过程。随后会看到判断过程。接下来你会注意到,有两个产生动画效果的代码块。第二个代码块在第一个代码块执行结束的时候开始。其中的原因有两点:

  • 我们在第一个代码块中对当前标签执行旋转和更改标签文本颜色的操作(参见关于currentLabelIndex属性的描述)。
  • 动画效果的子过程结束时,想要将标签的状态复原,这个过程要优雅平缓,而不能显得突兀不自然。很明显,第二段起到动画效果的代码块起了作用。

在产生动画效果的代码块内,completion handler(完成处理程序)检查currentLabelIndex属性值是否有效。如果有效,就再次重复调用相同的函数。这样下一个标签就产生了动画效果。另外,所有标签都执行完动画过程后,就调用动画过程的第二个子过程的自定义方法(animateRefreshStep2())。

你肯定注意到了getNextColor()函数(在第一个动画效果代码块中)。之前说过,通过这个函数会得到当前带有动画效果控件的文本颜色。一会儿再看这部分。

我们现在解决动画效果第二个子过程,实现animateRefreshStep2()函数:

  1. func animateRefreshStep2() {
  2.     UIView.animateWithDuration(0.35, delay: 0.0, options: UIViewAnimationOptions.CurveLinear, animations: { () -> Void in
  3.         self.labelsArray[0].transform = CGAffineTransformMakeScale(1.5, 1.5)
  4.         self.labelsArray[1].transform = CGAffineTransformMakeScale(1.5, 1.5)
  5.         self.labelsArray[2].transform = CGAffineTransformMakeScale(1.5, 1.5)
  6.         self.labelsArray[3].transform = CGAffineTransformMakeScale(1.5, 1.5)
  7.         self.labelsArray[4].transform = CGAffineTransformMakeScale(1.5, 1.5)
  8.         self.labelsArray[5].transform = CGAffineTransformMakeScale(1.5, 1.5)
  9.         self.labelsArray[6].transform = CGAffineTransformMakeScale(1.5, 1.5)
  10.         }, completion: { (finished) -> Void in
  11.             UIView.animateWithDuration(0.25, delay: 0.0, options: UIViewAnimationOptions.CurveLinear, animations: { () -> Void in
  12.                 self.labelsArray[0].transform = CGAffineTransformIdentity
  13.                 self.labelsArray[1].transform = CGAffineTransformIdentity
  14.                 self.labelsArray[2].transform = CGAffineTransformIdentity
  15.                 self.labelsArray[3].transform = CGAffineTransformIdentity
  16.                 self.labelsArray[4].transform = CGAffineTransformIdentity
  17.                 self.labelsArray[5].transform = CGAffineTransformIdentity
  18.                 self.labelsArray[6].transform = CGAffineTransformIdentity
  19.                 }, completion: { (finished) -> Void in
  20.                     if self.refreshControl.refreshing {
  21.                         self.currentLabelIndex = 0
  22.                         self.animateRefreshStep1()
  23.                     }
  24.                     else {
  25.                         self.isAnimating = false
  26.                         self.currentLabelIndex = 0
  27.                         for var i=0; i<self.labelsArray.count; ++i {
  28.                             self.labelsArray[i].textColor = UIColor.blackColor()
  29.                             self.labelsArray[i].transform = CGAffineTransformIdentity
  30.                         }
  31.                     }
  32.             })
  33.     })
  34. }

我们使用了两个产生动画效果的代码块。在第一个代码块中,等比例放大了所有标签。请注意,这里无法使用循环结构完成任务(例如:for语句)。循环结构的执行过程与动画过程无关,这样在所有标签等比例放大执行完毕前循环结构早就执行结束了。

在程序执行“完成处理”部分,所有标签都完成了初始化处理。因此,这些标签再一次回到了初始状态。动画效果代码块内部的“完成处理”部分使用了if语句,刷新过程还在进行中,我们就做好了重新开始整个动画的准备。通过给currentLabelIndex属性简单地设置初始值(0)就能完成这个任务,调用第一个自定义方法来执行动画。下一步我们再来处理刷新结束后的事情。但是如果刷新结束了,可以通过修改isAnimating标志表示不再执行任何动画,而通过给所有的属性(和视图)赋初值来参与动画过程。这样一来,动画过程要在下一次拉动表视图的时候重新开始。

问题出来了,自定义动画应该在哪里开始呢?如果你仔细观察过上面的动画,就会发现每次表视图拖动完成,动画就开始一次。从编程的角度说,表视图是滚动视图(scroll view)的子类,我们所关心的代理方法是scrollViewDidEndDecelerating(_:)。每次表视图滚动停止时,该方法都会被调用。这个方法起初会检查刷新过程是否在进行。就我们这里的情况而言,就是要检查isAnimating标志的值。如果没有动画在进行中,就要调用之前实现的第一个函数来做初始化。就像下面这样的代码:

  1. func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
  2.     if refreshControl.refreshing {
  3.         if !isAnimating {
  4.             animateRefreshStep1()
  5.         }
  6.     }
  7. }

值得注意的是,上述滚动视图代理方法的使用并非强制性的,这取决于自定义pull-to-refresh控件的代码逻辑。也可以考虑其他代理方法,比如scrollViewDidScroll(_:)。

还落一部分没有说,那就是getNextColor()函数的实现:

  1. func getNextColor() -> UIColor {
  2.     var colorsArray: Array<UIColor> = [UIColor.magentaColor(), UIColor.brownColor(), UIColor.yellowColor(), UIColor.redColor(), UIColor.greenColor(), UIColor.blueColor(), UIColor.orangeColor()]
  3.     if currentColorIndex == colorsArray.count {
  4.         currentColorIndex = 0
  5.     }
  6.     let returnColor = colorsArray[currentColorIndex]
  7.     ++currentColorIndex
  8.     return returnColor
  9. }

这非常简单!首先将一些预定义的颜色(顺序完全随机)放到数组当中。然后确认currentColorIndex属性的值。如果不具有有效的值,就要赋初值(0)。使用currentColorIndex表示颜色,然后累加其值,因此,再次getNextColor()的时候后就不会得到相同的颜色。函数最后将选定的颜色返回。

现在可以再次尝试运行应用。拖动后刷新,会看到动画效果。当然,刷新控件并不会消失。这一部分还没有实现。随便怎么去用,动画的任何一部分都可以修改。

自定义动画之外的事情

为pull-to-refresh控件创建自定义动画很有趣,但不要忘了,用户刷新不是光为了观看控件有多好看,用户需要获得新的内容。这也是自定义pull-to-refresh控件时你的主旨思想。因此,完成了上面这些内容以后,下一步就要实现真正获取数据的过程。

显然,本文并未涉及获取任何数据的操作,也没有对表视图更新内容的操作。但是,这些都无法阻止我们实施之前所描述的逻辑。所以要继续完成其余的任务,那就是创建一个新的自定义函数,名为doSomething()(蛮滑稽的名字,是不是觉得是个啥也干不了的函数)。我们要在该函数中初始化一个计时器(NSTimer)对象。4秒钟的时间间隔后,就会发出刷新过程结束的信号。在真实的应用软件中,没有必要这样做。当获取数据的过程结束时,刷新过程也就结束了。

首先,回到类定义的开始,(最后一次)声明一个对象:

  1. var timer: NSTimer!

现在来“do something”:

  1. func doSomething() {
  2.     timer = NSTimer.scheduledTimerWithTimeInterval(4.0, target: self, selector: “endOfWork”, userInfo: nil, repeats: true)
  3. }

4秒钟已经足够了,这样可以不止一次地看到动画效果。在上面的方法中可以看到仅有的一行代码,预设的时间间隔过后endOfWord()就会被调用。这样就会停止刷新过程,使计时器失效:

  1. func endOfWork() {
  2.     refreshControl.endRefreshing()
  3.     timer.invalidate()
  4.     timer = nil
  5. }

这个时候我们几乎大功告成了。唯一要做的就是调用doSomething()函数,要在动画过程开始前调用。因此需要再次修改滚动视图的代理方法:

  1. func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
  2.     if refreshControl.refreshing {
  3.         if !isAnimating {
  4.             doSomething()
  5.             animateRefreshStep1()
  6.         }
  7.     }
  8. }

示例程序做好了,这回再体验一把!

t38_1_final_sample

总结

你也看到了,自定义一个pull-to-refresh控件一点儿都不难。只是把好的创意用图形表现出来,仅此而已。正如我在最后的部分所讲的,主旨在于真实数据的获取过程,而不是炫耀所创建的可视化效果。还要注意的是数据一旦更新完毕,就不要惦记着延长时间而阻止刷新控件隐藏。这样做会导致你不愿意看到的用户体验,很糟糕。如果应用软件对用户非常有帮助的话,就会有很多机会让用户领略软件的自定义效果,所以不要试图更新完毕后还阻止刷新控件隐藏。文中示例程序所自定义的内容比较简单,但足以说明问题。你知道,这是编程方面的问题,可以接受很多自定义的内容和即兴创作。当然,最终的应用软件甚至可能会彼此各不相同。自定义的pull-to-refresh控件要与不同的应用相匹配。总之,本文就此告一段落,真心希望能对你有所帮助。能够帮助你愉快地开发出自定义的pull-to-refresh控件!

作为参考,可以从这里下载整个项目

http://www.csdn.net/article/2015-09-11/2825686

发表评论

电子邮件地址不会被公开。 必填项已用*标注