浅析饿了么,仿抖音下拉刷新

日期:2019-09-15编辑作者:编程应用

难度:⭐️⭐️效果:

话不多说,先上DEMO记得star哦

图片 1下拉刷新图片 2下拉进入活动会场

图片 3效果图

饿了么App在最近版本上线了一个新的活动会场进入方式,没错儿,就是类似 于淘宝首页的下拉刷新-继续下拉进入活动会场。这对我们本身就已经很复杂的View Hierachy提出了不小的挑战。本篇文章带你一步一步解析这样的全屏下拉、普通下拉刷新的实现方式。

Swift Version 3.0Xcode 8.1默认缩进 2空格

既然是仿抖音效果,那首先就是要分析这个效果的实现思路,根据观察,实现思路大致如下(如果你有什么更好的方案也不妨告诉我哦,交流使人进步):1、上拉时页面有翻页效果,可以用scrollview的pagingEnabled来实现,也就是说列表页不管你用tableview还是collectionview,只要每个cell是全屏的就可以2、下拉:当页面不是停留在第一个cell时,下拉就只是scrollView的滚动效果,不会触发刷新,当页面停留在第一个cell,也就是说scrollView.contentOffset.y

首先我们想要的效果是:

0的时候,手指下拉才会触发刷新效果,并且下拉时scrollView不动,也就是没有scrollview的弹性效果,因此scrollView.bounces

NO3、既然下拉时scrollView不动,就不能使用代理来监听scrollView的滑动实现刷新,于是我想到了用touches的系列方法来监控手指下滑位移;4、动画分解有五步:下拉时“推荐、附近”的那个导航条和“下拉刷新内容”的视图有渐隐渐显的效果,位置也随着手指下移,可以通过手指下滑位移计算alpha来实现下拉时,“下拉刷新内容”的视图右边那个有缺口的小圆环会随着手指滑动转圈,下滑时逆时针旋转下滑一定距离后如果不松手,又继续上滑,会执行前两步的反效果,圆环顺时针旋转,手指停在屏幕上,圆环就停止转动下滑到某个临界点,导航条和刷新视图都不再移动(此时导航条已经完全透明),所以可以通过计算起始点和当前点移动距离来计算透明度、位移、旋转角度,这些操作都在touchesMoved中实现到临界点松手后,导航条和刷新视图都回到原始位置,小圆环一直顺时针转圈,直到刷新结束,停止动画,隐藏刷新视图,显示导航条,如果没达到临界点就松手,不会触发刷新

描述的有点多,但是只有仔细分析了才能有个清晰的思路,实现的时候也就会少走一些弯路。写代码最忌拿到功能还没想好就开始干,结果实现的时候遇到太多的坑,反反复复浪费时间。

好了,思路整理了之后那么就一步步实现吧

self.tableView.showPullPromotion = true
一、基础功能

创建tableview、mainViewNavigitionView、RefreshNavigitionView(刷新视图,初始alpha为0)、startPoint,基本样式都写完之后就开始运行了图片 4层级关系就是这样

运行起来大面上一看,嗯,长得还挺像的,上拉翻页也没问题,但是,重点来了:

这一行代码就能启用整个下拉刷新,那么就需要一个 UIScrollView的 extension (aka category in objc).其次,整个一屏显示的 UIImageView的层次处于 UIScrollView中,势必需要为 UIScrollView动态添加这么一个用于显示图片的自定义 View,我定义其为:

我手指下滑的时候touchesBegan等系列方法根本就没走,what?这怎么办,说好的监听手指移动距离的,方法都不走我怎么监听?

经过一番搜索查证,原来是事件响应链的问题,当我们点击屏幕时,第一响应者应该是UITableView,而我们调用的touchBegan其实是ViewController的View的方法,所以无法被调用,如果不了解的话下面两篇文章可以帮到你:从iOS的事件响应链看TableView为什么不响应touchesBegan让UITableView响应touch事件根据文中方法,我给TableView写了个基类,添加了touches相关的一些代理方法,运行起来,终于可以监听手指移动了

class PullPromotionView: UIView { weak var scrollView:UIScrollView? convenience init() { var rect = UIScreen.main.bounds rect.origin.y = -rect.size.height self.init(frame:rect) commonInit() } func commonInit() { loadImageView() }}
但是,问题又来了,我在touchesMoved打印了手指触摸点的y值,我发现手指滑动一会儿后控制台就不再打印了,每次位移大概十几个像素,并且松手后touchesEnded方法也不怎么走(这个方法不太灵光啊)

于是我把TableView先注掉,让手指直接触摸在self.view上,看看touches方法是否正常,事实证明是没问题的

PullPromotionView里定义了一个指向其所在的 scrollView的弱引用,这个引用将被用于:PullPromotionView作为 scrollView 的 Observer,监听其 contentOffset变化的同时判断下拉的状态。这时候我们添加一个 UIScrollView的 extension:

把tableView解开依然不好使(不好使的原因我还没有深究,如果有人知道,不妨告诉我啦,谢谢),既然手指直接摸在self.view上是好使的,而且touchBegan其实是ViewController的View的方法,那我是不是可以在tableView上面覆盖一层透明的view,通过滑动判断来隐藏和显示它,实现下拉刷新,上拉翻页(上拉时隐藏view,手指就摸在tableView上,就是拖拽手势了)
private var PULL_REFRESH_PROPERTY = 0extension UIScrollView { var pullPromotionView:PullPromotionView? { get { return getPullPromotionView() } set { setPullPromotionView(view: newValue) } } func getPullPromotionView() -> PullPromotionView? { let view = objc_getAssociatedObject(self, &PULL_REFRESH_PROPERTY) if view == nil { createPullPromotionView() } return objc_getAssociatedObject(self, &PULL_REFRESH_PROPERTY) as? PullPromotionView } func setPullPromotionView(view:PullPromotionView?) { self.willChangeValue(forKey: NSStringFromSelector(#selector(getter: pullPromotionView))) objc_setAssociatedObject(self, &PULL_REFRESH_PROPERTY, view, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) self.didChangeValue(forKey: NSStringFromSelector(#selector(getter: pullPromotionView))) } func createPullPromotionView() { let view = PullPromotionView() self.addSubview view.scrollView = self view.layer.zPosition = 1 setPullPromotionView(view: view) }}
二、动画效果

根据上面的想法,初步实现了手指触摸的系列操作,但是还有许多细节需要注意,就是clearview的隐藏和显示的临界点,思路如下:1、页面初始,clearview显示,但背景色是透明的,用户看不到,判断手指滑动位移,如果是下拉,就执行下拉刷新的那些操作,以及动画,如果是上拉,上拉到某个临界点,就翻页,并且隐藏clearview,这样用户下次下拉的时候就不会触发touch的方法,而是tableView的向下拖拽翻页2、监听tableView的滑动,如果滑动到第一个cell停止了,就要让clearview显示,有可能用户会继续下滑,就会触发touch的方法,执行1的操作3、触摸结束时,需要恢复导航条和刷新视图的frame,如果此时RefreshNavigitionView的alpha不为1,说明没有下拉到临界点,各自透明度也要恢复到初始状态,如果是1,就要走刷新的回调

因为我们要在 extension中添加一个PullPromotionView 作为property,所以需要使用 runtime动态地去执行,复写一下 getter 和 setter 就 OK了, 这样通过 self.pullPromotionView 引用的就是 property,可以正确地帮我们保存各种上下文参数。然后我们在这个 extension中添加一个可以帮我们一行代码启用的 property:

到这里基本上上拉下拉的操作都可以顺畅完成了,接下来就该实现动画了,frame的移动,以及松手后圆环一直转圈这些都好做,困住我的是手指下拉时圆环随着手指下滑位移旋转,也就是说它既要随着父视图RefreshNavigitionView下移,还要以自己为中心旋转,手指滑动它就转,手指不动它就不转

旋转动画我选的是transform,松手后圆环旋转用的是CABasicAnimation,但它是layer动画,动画结束后会复位,实际上view本身没有转动,使用过程中就会出现圆环转一下回去又转一下又回去的卡顿现象(当然也可以用代码让它不要复位:CABasicAnimation使用总结 比较麻烦,代码也比transform多,transform只需一行代码即可旋转)transform是叠加效果,可以根据上次旋转的角度继续旋转,如果我把度数写成固定值,那么圆环就会随着手指移动均匀旋转,动画也比较流畅

理想很丰满,现实很残酷呀。transform动画写上之后,圆环居然随着手指移动乱转,一会放大,一会缩小,一会翻转,网上查了各种transform的使用方法,我写的没问题啊,着实困了我不少时间,只好求助小伙伴了。经查证是自动布局的锅,transform是frame动画,需要圆环确切的frame,而我用的是SDAutolayout,就算updatelayout也不好使,小圆环的位置如果改成frame,动画就没问题了,然后又试了masonry,也是好使的,所以说有时候老框架的优势还是很明显的

核心代码如下,注释写的很清楚:

-touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{ if (self.scrollView.contentOffset.y <=0&&self.refreshStatus == REFRESH_Normal) { //当tableview停在第一个cell并且是正常状态才记录起始触摸点,防止页面在刷新时用户再次向下拖拽页面造成多次下拉刷新 startPoint = [touches.anyObject locationInView:self.view]; NSLog(@"startPoint:%.f",startPoint.y); }else{ //否则就隐藏透明视图,让页面能响应tableview的拖拽手势 _clearView.hidden = YES; }}-touchesMoved:touches withEvent:(UIEvent *)event{ if (CGPointEqualToPoint(startPoint,CGPointZero)) { //没记录到起始触摸点就返回 return; } CGPoint currentPoint = [touches.anyObject locationInView:self.view]; float moveDistance = currentPoint.y-startPoint.y; if (self.scrollView.contentOffset.y <=0) { //根据触摸点移动方向判断用户是下拉还是上拉 if(moveDistance>0&&moveDistance<MaxDistance) { self.refreshStatus = REFRESH_MoveDown; //只判断当前触摸点与起始触摸点y轴方向的移动距离,只要y比起始触摸点的y大就证明是下拉,这中间可能存在先下拉一段距离没松手又上滑了一点的情况 float alpha = moveDistance/MaxDistance; //moveDistance>0则是下拉刷新,在下拉距离小于MaxDistance的时候对_refreshNavigitionView和_mainViewNavigitionView进行透明度、frame移动操作 _refreshNavigitionView.alpha = alpha; CGRect frame = _refreshNavigitionView.frame; frame.origin.y = moveDistance; _refreshNavigitionView.frame = frame; if (_mainViewNavigitionView) { _mainViewNavigitionView.alpha = 1-alpha; frame = _mainViewNavigitionView.frame; frame.origin.y = moveDistance; _mainViewNavigitionView.frame = frame; } //在整体判断为下拉刷新的情况下,还需要对上一个触摸点和当前触摸点进行比对,判断圆圈旋转方向,下移逆时针,上移顺时针 CGPoint previousPoint = [touches.anyObject previousLocationInView:self.view];//上一个坐标 if (currentPoint.y>previousPoint.y) { _refreshNavigitionView.circleImage.transform= CGAffineTransformRotate(_refreshNavigitionView.circleImage.transform,-0.08); }else _refreshNavigitionView.circleImage.transform= CGAffineTransformRotate(_refreshNavigitionView.circleImage.transform,0.08); } else if(moveDistance>=MaxDistance) { self.refreshStatus = REFRESH_MoveDown; //下拉到最大点之后,_refreshNavigitionView和_mainViewNavigitionView就保持透明度和位置,不再移动 _refreshNavigitionView.alpha = 1; if (_mainViewNavigitionView) { _mainViewNavigitionView.alpha = 0; } }else if(moveDistance<0) { self.refreshStatus = REFRESH_MoveUp; //moveDistance<0则是上拉 根据移动距离修改tableview.contentOffset,模仿tableview的拖拽效果,一旦执行了这行代码,下个触摸点就会走外层else代码 self.scrollView.contentOffset = CGPointMake(0, -moveDistance); } }else{ self.refreshStatus = REFRESH_MoveUp; //tableview被上拉了 moveDistance = startPoint.y - currentPoint.y;//转换为正数 if (moveDistance>MaxScroll) { //上拉距离超过MaxScroll,就让tableview滚动到第二个cell,模仿tableview翻页效果 _clearView.hidden = YES; //[self.tableview scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:1 inSection:0] atScrollPosition:UITableViewScrollPositionNone animated:YES]; [UIView animateWithDuration:0.3 animations:^{ self.scrollView.contentOffset = CGPointMake(0, kHeight); }]; }else if(moveDistance>0&&moveDistance<MaxScroll){ self.scrollView.contentOffset = CGPointMake(0, moveDistance); } }}- touchesEnded:touches withEvent:(UIEvent *)event{ //清楚起始触摸点 startPoint = CGPointZero; //触摸结束恢复原位-松手回弹 [UIView animateWithDuration:0.3 animations:^{ CGRect frame = _refreshNavigitionView.frame; frame.origin.y = 0; _refreshNavigitionView.frame = frame; if (_mainViewNavigitionView) { frame = _mainViewNavigitionView.frame; frame.origin.y = 0; _mainViewNavigitionView.frame = frame; } if (self.scrollView.contentOffset.y<MaxScroll) { //没滚动到最大点,就复原tableview的位置 self.scrollView.contentOffset = CGPointMake; } }]; //_refreshNavigitionView.alpha=1的时候说明用户拖拽到最大点,可以开始刷新页面 if (_refreshNavigitionView.alpha == 1) { self.refreshStatus = XDREFRESH_BeginRefresh; //刷新图片 [self startAnimation]; if (self.refreshBlock) { self.refreshBlock(); } }else { //没下拉到最大点,alpha复原 [self resumeNormal]; }}

使用方法很简单,下载工程后,将JXRefresh文件夹拖入工程,vc继承JXRefreshViewController,然后写下列代码即可

__weak typeof weakSelf = self; [self addJXRefreshWithTableView:self.tableview andNavView:self.mainViewNavigitionView andRefreshBlock:^{ //此处写你刷新请求的方法,我这里是模拟刷新 dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [weakSelf endRefresh]; }); }];
var showPullPromotion:Bool { get { return self.pullPromotionView!.isHidden } set { self.pullPromotionView?.isHidden = !newValue if !self.pullPromotionView!.isObserving { self.addObserver(self.pullPromotionView!, forKeyPath: NSStringFromSelector(#selector(getter: contentOffset)), options: NSKeyValueObservingOptions.new, context: nil) } else if self.pullPromotionView!.isObserving { self.removeObserver(self.pullPromotionView!, forKeyPath: NSStringFromSelector(#selector(getter: contentOffset))) } self.pullPromotionView!.isObserving = !self.pullPromotionView!.isObserving } }
注意点:使用时只需要初始化tableview 和mainViewNavigitionView就好,不要添加到self.view上

代码质量和封装效果差点(我还是有自知之明的),肯定可以有更优的实现效果的,可以参照下思路呀,有问题及时反馈哈

如果觉得对您有用,点赞打赏关注一下呗,^ _ ^你们的支持是我最大的动力,谢谢

需要在 PullPromotionView中添加一个名为 isObserving 的 Bool类型 property

通过上面的代码可以看到,我们在设置 self.tableView.showPullPromotion = true的时候同时改变 pullPromotionView 的可见性和它的 Obsever。Observer被设置为这个 pullPromotionView,那么我们就可以在 PullPromotionView里面实现和控制整个 UIScrollView了。

为了表示下拉的状态,我定义了一个枚举:

enum PullPromotionState { case stopped //停止状态 case refreshTriggered //触发刷新 case promotionTriggered //触发全屏下拉 case refreshing //刷新中 case promotionShowing //全屏滑动显示中}

有了这个状态的定义,我为 PullPromotionView添加了一个表示状态的 property,并且在监听到 scrollView的 contentOffset变化时改变这个状态.

let RefreshTriggerHeight:CGFloat = 70let PromotionTirggerHeight:CGFloat = 100class PullPromotionView: UIView { ...... ...... override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { if keyPath == "contentOffset" { let point = change?[NSKeyValueChangeKey.newKey] as! CGPoint scrollViewDidScroll(to: point) } } func scrollViewDidScroll(to contentOffset:CGPoint) { if self.state == .refreshing { return } let scrollOffsetRefreshHold = -RefreshTriggerHeight let scrollOffsetPromoteHold = -PromotionTirggerHeight if !self.scrollView!.isDragging && self.state == .refreshTriggered { self.state = .refreshing } else if !self.scrollView!.isDragging && self.state == .promotionTriggered { self.state = .promotionShowing } else if contentOffset.y < scrollOffsetRefreshHold && contentOffset.y > scrollOffsetPromoteHold && self.scrollView!.isDragging && self.state == .stopped { self.state = .refreshTriggered } else if contentOffset.y < scrollOffsetPromoteHold && (self.state == .stopped || self.state == .refreshTriggered) && self.scrollView!.isDragging { self.state = .promotionTriggered } else if contentOffset.y >= scrollOffsetRefreshHold && self.state != .stopped { self.state = .stopped } }}

几个状态的触发点分别是:

  • scrollView不在拖动中,前一个状态是触发刷新。 => 刷新中
  • scrollView不在拖动中,前一个状态是触发全屏下拉。 => 全屏滑动显示中
  • scrollView 的滑动距离大于刷新触发,小于全屏下拉触发, 在拖动中,前一个状态是停止 => 触发刷新
  • scrollView 的滑动距离大于全屏触发点,前一个状态是停止或下拉刷新被出阿发,在拖动中 => 全屏下拉被触发
  • scrollView 的滑动距离小于刷新触发,前一个状态非停止状态 => 停止

在设置状态时,我们同时要更改一些 UI的显示,如下:

typealias CallBack = () -> Void var _state:PullPromotionState = .stopped var state:PullPromotionState { get { return _state } set { if _state == newValue { return } dispatchState(state: newValue) } } var refreshAction:CallBack? var promotionAction:CallBack? func dispatchState(state:PullPromotionState) { let previousState = _state _state = state switch state { case .refreshing: setScrollViewForRefreshing() if previousState == .refreshTriggered { //do refresh action if self.refreshAction != nil { self.refreshAction!() } } break case .promotionShowing: setScrollViewForPromotion() //do show promotion action if self.promotionAction != nil { self.promotionAction!() } break case .stopped: resetScrollView() break default: break } }

具体可以解释为不同状态下 scrollView需要有不同的 contentInset 和 contentOffset, setScrollViewForRefreshing()/setScrollViewForPromotion()/resetScrollView()三个方法的实现如下:

 func setScrollViewForRefreshing() { var currentInset = self.scrollView?.contentInset currentInset?.top = RefreshTriggerHeight let offset = CGPoint(x: self.scrollView!.contentOffset.x, y: -currentInset!.top) animateScrollView(contentInset: currentInset!, contentOffset: offset, animationDuration: 0.2) } func setScrollViewForPromotion() { var currentInset = self.scrollView?.contentInset currentInset?.top = self.bounds.size.height let offset = CGPoint(x: self.scrollView!.contentOffset.x, y: -currentInset!.top) self.scrollView?.contentInset = currentInset! self.scrollView?.setContentOffset(offset, animated: true) } func resetScrollView() { var currentInset = self.scrollView?.contentInset currentInset?.top = 0 let offset = CGPoint(x: self.scrollView!.contentOffset.x, y: -currentInset!.top) animateScrollView(contentInset: currentInset!, contentOffset: offset, animationDuration: 0.2) } func animateScrollView(contentInset:UIEdgeInsets, contentOffset:CGPoint, animationDuration:CFTimeInterval) { UIView.animate(withDuration: animationDuration, delay: 0, options: [.allowUserInteraction, .beginFromCurrentState], animations: { self.scrollView?.contentOffset = contentOffset self.scrollView?.contentInset = contentInset }, completion: nil) }

setScrollViewForPromotion() 这个方法没有使用和其他一样的 animateScrollView(contentInset)方法设置全屏滑动是因为在UITableView中一屏的距离过长,UIView animate 开始的时候渲染树认为 UITableViewWrapperView已经在屏幕外了,导致动画无衔接出现空白 View的现象。如果需要自定义这一块的动画时长和效果,可以使用 CADisplayLink手动控制。

至此,我们已经可以在一个 tableView 中看到基本的效果了。但是我们需要一个显示 “释放可刷新”的视图,并且跟随状态变化而变化, 本例中具体代码如下:

class RefreshControl: UIView { var _state:PullPromotionState = .stopped var state:PullPromotionState { get { return _state } set { _state = newValue dispatchState(state: newValue) } } var hintLabel = UILabel() let refreshHint = "下拉可刷新" let releaseHint = "释放可刷新" let refreshingHint = "正在刷新" let promotionHint = "双11会场" convenience init() { let rect = UIScreen.main.bounds self.init(frame:CGRect(x: 0, y: 0, width: rect.size.width, height: RefreshTriggerHeight)) self.top = rect.size.height - RefreshTriggerHeight self.hintLabel.text = self.refreshHint self.hintLabel.textColor = UIColor.white self.hintLabel.font = UIFont.systemFont(ofSize: 12) self.addSubview(self.hintLabel) } func dispatchState(state:PullPromotionState) { self.isHidden = state == .promotionShowing switch state { case .promotionTriggered: self.hintLabel.text = self.promotionHint break case .promotionShowing: self.hintLabel.text = nil break case .refreshing: self.hintLabel.text = self.refreshingHint break case .stopped: self.hintLabel.text = refreshHint break case .refreshTriggered: self.hintLabel.text = self.releaseHint break; } self.setNeedsLayout() self.layoutIfNeeded() } override func layoutSubviews() { super.layoutSubviews() self.hintLabel.sizeToFit() self.hintLabel.left = (self.width - self.hintLabel.width) / 2 self.hintLabel.bottom = RefreshTriggerHeight - 8 } }

然后,把它加入到 PullPromotionView中去:

class PullPromotionView: UIView { ...... var hud = RefreshControl() //记得 set State的时候一并设置 hud的 state ...... func commonInit() { loadImageView() self.addSubview }}

这时候,找个 ViewController 试一下这个效果:

 self.tableView.showPullPromotion = true self.tableView.pullPromotionView?.refreshAction = { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: { self?.tableView.pullPromotionView?.stopAnimate }

关于文中代码引用 UIView 的 height/left/top/right/bottom, 这是通过一个 extension 实现的获取 frame的便捷方法,此处不再赘述.

就能看到和开头一样的效果了。这样,一个全屏下拉的交互就做完了。接下来我们还可以做的有:把上次写的小箭头动效添加进来、给背景图加上基本的视差动画,这样就能显示出更好的效果了。

本例源码: Github

- EOF -

本文由今晚最快开奖现场直播发布于编程应用,转载请注明出处:浅析饿了么,仿抖音下拉刷新

关键词:

Masonry简单使用,Masonry介绍与使用实践

1)Masonry是一个轻量级的布局框架,拥有自己的描述语法,采用更优雅的 链式语法 封装自动布局,简洁明了并具有...

详细>>

增加和删除改查

一.文章概要 当前使用工具是XCode7. 这篇文章主要是写了对于基本数据类型的"增删改查"的操作,至于特殊类型比如UII...

详细>>

面向公约开采笔记,Swift编制程序最佳体验之Ge

关于Swift代码风格,能让绝大部分人的眼前一亮,WTF?还能这样写?今天就带来一段Swift常见的Generic 比如 UIButton,U...

详细>>

利用Intent来开展App间的主导交互,系统深远学习

Intent是Android中存放一个操作的抽象描述的数据结构,可用于在不同的组件以及不同的app间进行传递,是消息的载体,有显...

详细>>