仿<即刻>下拉刷新效果实现

个人还是比较喜欢<即刻>这个应用,界面的效果与交互都是比较清爽,想模仿一下里面的部分交互效果,于是决定先从不是那么复杂的下拉刷新入手,并记录下自己实现的思路。另,本 Demo 是按照个人的思路来实现的,仅供学习交流,Demo 下载链接见最后。

界面分析

即刻APP本身效果图如下:

通过效果图,可以观察出以下几点结论:

  • 默认界面静止的情况下刷新控件是在 scrollView 的最上面,默认隐藏.(这句话是废话)
  • 当用户慢慢往下拖动的时候会出现一个灰色的 J 的字母慢慢被深色给填满
  • 当用户拖动距离大于某个范围的时候,控件的不再是紧贴着 scrollView 往下移动,此时的移动速度会放缓,保证刷新控件的 J 大概在 scrollView的内容顶部与界面顶部的中间位置
  • J 在没有完全填满的情况下松手,刷新控件会回原位(这句好像也是废话)
  • J 在完全填满的情况下松手,那么此时这个 J 会慢慢变成成一个圆圈,变化过程是 J 的底部的弧形会变成圆形,并且 J 的上半部分的竖线会慢慢变短,并且进行刷新状态
  • 进入刷新状态之后,圆圈进行旋转动画
  • 刷新完成圆圈慢慢变细并且刷新控件回到最初的位置

经过以上一堆乱七八糟的分析之后,接下来再使用 Reveal 查看一下刷新控件的层次结构,如下:

再通过查看层次结构,也可以总结出以下几条:

  • 刷新控件继承于 UIView,并且内部并没有其他子控件,所以推断出里面的内容都是自己画出来的
  • 刷新控件的大小 45 45,默认 centerY 值是 -35 [(-自已的高度 0.5) - 12.5]

接下来我们就可以根据我们分析的功能一条一条的来慢慢实现。

功能实现

1. 创建刷新控件

创建 Swift 项目,自定义刷新控件类,取名为 JKRefreshControl,并实现 initWithFrame 方法

1

2

3

4

5

6

7

8

9

10

class JKRefreshControl: UIView {

override init(frame: CGRect) {

super.init(frame: frame)

}

required init?(coder aDecoder: NSCoder) {

fatalError("init(coder:) has not been implemented")

}

}

先确定当前控件的大小,定义一个私有常量:

1

2

private let RefreshControlWH: CGFloat = 45

添加 setupUI 方法,在该方法中初始化控件,并在 initWithFrame 里面调用该方法

1

2

3

4

5

6

private func setupUI(){

frame.size = CGSize(width: RefreshControlWH, height: RefreshControlWH)

backgroundColor = UIColor.red

}

在控制器中 viewDidLoad 初始化该控件,添加到 scrollView 中测试

1

2

3

4

5

6

override func viewDidLoad() {

super.viewDidLoad()

let refresh = JKRefreshControl()

scrollView.addSubview(refresh)

}

查看效果:

2. 调整刷新控件位置

  • 需求:刷新控件要放在 scrollView 的最顶端并默认隐藏,所以 y 值是为负,并且其中心 x 是根据其父控件的宽度来进行计算不能写死。
  • 思考:调整刷新控件的位置的代码可以写在刷新控件内部,为了别人使用起来方便(系统的刷新控件的大小与位置都不需要使用者去考虑)。
  • 问题:如何取到刷新控件的父控件,也就是说应该在哪个地方去取到父控件并设置值是最合适的。
  • UIView 有一个方法:willMoveToSuperView 可以利用一个,可以在这个方法里面取到父控件,并且可以使用 KVO 监听父控件的 frame 变化,根据父控件的 frame 变化去调整当前刷新控件的位置,代码实现如下:

1

2

3

4

5

6

7

8

9

10

11

override func willMove(toSuperview newSuperview: UIView?) {

super.willMove(toSuperview: newSuperview)

if let superView = newSuperview as? UIScrollView {

self.superView = superView

superView.addObserver(self, forKeyPath: "frame", options: NSKeyValueObservingOptions.new, context: nil)

superView.addObserver(self, forKeyPath: "contentOffset", options: NSKeyValueObservingOptions.new, context: nil)

}

}

添加通过传入父控件的 frame 去确定刷新控件位置的代码:

1

2

3

4

5

6

7

private func setLocation(superViewFrame: CGRect) {

self.center = CGPoint(x: superViewFrame.width * 0.5, y: -self.frame.height * 0.5 - 12.5)

}

添加 KVO 的值改变的处理:

1

2

3

4

5

6

7

8

9

10

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {

if keyPath == "contentOffset" {

}else if keyPath == "frame" {

let value = (change![NSKeyValueChangeKey.newKey] as! NSValue).cgRectValue

self.setLocation(superViewFrame: value)

}

}

另,因为后面需要根据 scrollView 的滚动距离去计算当前控件的状态,所以也可以在这个地方去使用 KVO 监听 scrollView 的 contentOffset 属性。关于为什么要什么 KVO 去监听滚动而不使用 scrollView 的代理方法呢,因为如果刷新控件成为 scrollView 的代理,那么就不能允许其他类比如控制器成为 scrollView 的代理,我们要做的就是让外界关心事情越少越好。

运行测试效果:

3. 内部图案分析

  • 内部的图案再看层次结构分析它并不是一个 View,是画出来的
  • 有一个灰色背景的 J,可以一个使用 CAShapeLayer 来实现
  • 根据用户拖动而变化的 J 我的方式是使用两个 CAShapeLayer 来实现,一个 layer 实现字母 J 下面1/4圆的绘制,另一个 layer 实现字母 J 上面竖直图形的绘制

如图所示:

定义一些常量,供设置到 layer 上去和后面绘图的时候使用

1

2

3

4

5

6

7

8

9

10

11

private let ThemeColor = UIColor(red: 59/255, green: 84/255, blue: 106/255, alpha: 1)

private let LineWidth: CGFloat = 5

private let LineHeight: CGFloat = 16

private let InnerRadius: CGFloat = 8

private let DrawCenter = CGPoint(x: RefreshControlWH * 0.5, y: RefreshControlWH * 0.5)

定义 3 个 layer,并在 setupUI 方法中添加到刷新控件的 layer 中:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

fileprivate lazy var bgGrayLayer: CAShapeLayer = {

let layer = CAShapeLayer()

let bgColor = UIColor(red: 222/255, green: 226/255, blue: 229/255, alpha: 1)

layer.fillColor = bgColor.cgColor

layer.strokeColor = bgColor.cgColor

return layer

}()

fileprivate lazy var bottomLayer: CAShapeLayer = {

let layer = CAShapeLayer()

layer.fillColor = UIColor.clear.cgColor

layer.strokeColor = ThemeColor.cgColor

layer.lineWidth = self.lineWidth

layer.frame = self.bounds

return layer

}()

fileprivate lazy var topLayer: CAShapeLayer = {

let layer = CAShapeLayer()

layer.strokeColor = ThemeColor.cgColor

layer.lineWidth = self.lineWidth

return layer

}()

...

private func setupUI(){

frame.size = CGSize(width: RefreshControlWH, height: RefreshControlWH)

backgroundColor = UIColor.clear

layer.addSublayer(bgGrayLayer)

layer.addSublayer(bottomLayer)

layer.addSublayer(topLayer)

}

4. 背景灰色 J 的实现

添加一个 extension, 专门用于更新界面,并提供 drawInLayer 方法绘制 layer 中的内容:

1

2

3

4

5

6

7

8

extension JKRefreshControl {

fileprivate func drawInLayer() {

}

}

drawInLayer 方法中实现绘制灰色背景 J 的逻辑:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

fileprivate func drwaInLayer() {

let path = UIBezierPath(arcCenter: DrawCenter, radius: InnerRadius, startAngle: startAngle, endAngle: endAngle, clockwise: false)

path.addLine(to: CGPoint(x: path.currentPoint.x, y: DrawCenter.y - LineHeight))

path.addLine(to: CGPoint(x: path.currentPoint.x + LineWidth, y: path.currentPoint.y))

path.addLine(to: CGPoint(x: path.currentPoint.x, y: path.currentPoint.y + LineHeight))

path.addArc(withCenter: DrawCenter, radius: InnerRadius + LineWidth, startAngle: endAngle, endAngle: startAngle - 0.05, clockwise: true)

path.close()

bgGrayLayer.path = path.cgPath

}

aaa在画外圆的时候,endAngle 减去了 0.05,原因是官方的效果的字母 J 的底部并不是一个标准的1/4圆,会少一点

setupUI 方法中调用该方法,运行测试效果如下:

我们上面说了,当用户拖动距离大于某个范围的时候,控件的不再是紧贴着 scrollView 往下移动,此时的移动速度会放缓,保证刷新控件的 J 大概在 scrollView的内容顶部与界面顶部的中间位置,所以添加 dealContentOffsetYChanged 方法用于处理 scrollView 的 contentOffsetY 值改变之后去处理刷新控件的位置:

1

2

3

4

private func dealContentOffsetYChanged() {

}

在 KVO 的值改变处理方法中调用该方法:

1

2

3

4

5

6

7

8

9

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {

if keyPath == "contentOffset" {

self.dealContentOffsetYChanged()

}else if keyPath == "frame" {

let value = (change![NSKeyValueChangeKey.newKey] as! NSValue).cgRectValue

self.setLocation(superViewFrame: value)

}

}

需要实现的功能:当拖动的范围的2分之1大于控件的中心 y 值的时候,需要设置刷新控件的中心 y 值为 scrollView 内容顶部到 scrollView 的顶部的中间位置,具体见下图:

添加 defaultCenterY 属性记录控件的默认 y 值:

1

2

3

4

lazy var defaultCenterY: CGFloat = {

return -self.frame.height * 0.5 - 12.5

}()

所以代码为:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

private func dealContentOffsetYChanged() {

let contentOffsetY = superView.contentOffset.y;

let result = (contentOffsetY + superView.contentInset.top) / 2

if result < defaultCenterY {

self.center = CGPoint(x: self.center.x, y: result)

}else{

self.center = CGPoint(x: self.center.x, y: defaultCenterY)

}

}

运行查看效果:

6. 根据拖动的距离填充字母 J

要根据拖动的距离填充字母,那么需要知道拖动距离与填充的范围的比例关系:

  • 例如:拖动的距离为50,填充字母 J 的范围比例为 50%
  • 而这个比例的公式为:比例 = 拖动的距离 / 控件的高度
  • 当拖动的距离已经完全将控件显示出来的话,那么就代表 J 被填满了。

所以,我们可以定义一个属性值去记录当前拖动范围求出来的比例:

1

2

3

4

5

6

7

8

9

10

11

12

13

var contentOffsetScale: CGFloat = 0 {

didSet {

if contentOffsetScale > 1 {

contentOffsetScale = 1

}

if contentOffsetScale <= 0 {

contentOffsetScale = 0

}

}

}

注:当比例值大于 1 的时候,就设置为 1,当比例值小于 0 的时候,就设置为 0

这个值在 dealContentOffsetYChanged 方法中计算出来:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

private func dealContentOffsetYChanged() {

...

let scale = -(superView.contentOffset.y + superView.contentInset.top) / RefreshingStayHeight

self.contentOffsetScale = scale

self.drawInLayer()

}

接下来的任务就是要通过比例关系去填充字母,实现的思路是:

  • 如果比例小于等于 0.5,只填充 bottomLayerbottomLayer 的填充范围是:比例 * 2
  • 如果比例大于 0.5,填满 bottomLayer,并且填充 topLayertopLayer 的填充范围是:(比例 - 0.5) * 2

所以在 drawInLayer 中添加实现代码:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

fileprivate func drawInLayer() {

...

if contentOffsetScale < 0.016 {

bgGrayLayer.path = nil

bottomLayer.path = nil

topLayer.path = nil

return

}

func pathForBottomCircle(contentOffsetScale: CGFloat) -> UIBezierPath {

var scale = contentOffsetScale

if scale > 0.5 {

scale = 0.5

}

let targetStartAngle = startAngle

let targetEndAngle = startAngle - startAngle * scale * 2

let drawPath = UIBezierPath(arcCenter: DrawCenter, radius: InnerRadius + LineWidth * 0.5, startAngle: targetStartAngle, endAngle: targetEndAngle, clockwise: false)

return drawPath

}

bottomLayer.path = pathForBottomCircle(contentOffsetScale: contentOffsetScale).cgPath

if contentOffsetScale <= 0.5 {

topLayer.path = nil

}else {

let topPath = UIBezierPath()

topPath.lineCapStyle = .square

topPath.move(to: CGPoint(x: DrawCenter.x + InnerRadius + LineWidth * 0.5, y: DrawCenter.y))

topPath.addLine(to: CGPoint(x: DrawCenter.x + InnerRadius + LineWidth * 0.5, y: DrawCenter.y - (contentOffsetScale - 0.5) * 2 * LineHeight))

topLayer.path = topPath.cgPath

}

}

运行测试,效果如图:

写到这一步为止,刷新控件的拖动变化效果就已经实现完毕,接下来的任务是当字母被填满的时候松手,进行转圈的动画,但是:

  • 在做转圈动画之前,我们需要先完成当字母被填满的时候松手控件会停留在顶部位置,而不会回到导航栏下面
  • 所以我们需要弄清楚刷新控件的几种状态,通过刷新控件的几种状态去设置不同状态下刷新控件的位置
    • 默认状态:被导航栏盖住(已完成)
    • 松手就可以刷新的状态:根据用户拖动去计算位置(已完成)
    • 刷新中状态:在导航栏的下面,并显示到界面上,实现思路是调整 scrollview 的 contentInset 的 top 实现增加 scrollView 的滚动范围

使用枚举定义刷新控件的状态:

1

2

3

4

5

6

7

8

enum JKRefreshState: Int {

case normal = 0, pulling, refreshing

}

在类中定义状态的属性 refreshState

1

2

fileprivate var refreshState: JKRefreshState = .normal

监听 scrollView 的滚动,在 dealContentOffsetYChanged 方法中调整刷新控件的状态:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

private func dealContentOffsetYChanged() {

let result = (contentOffsetY + superView.contentInset.top) / 2

...

if superView.isDragging {

if result < defaultCenterY && refreshState == .normal {

refreshState = .pulling

}else if result >= defaultCenterY && refreshState == .pulling {

refreshState = .normal

}

}else {

if refreshState == .pulling {

refreshState = .refreshing

}

}

...

}

refreshStatedidSet 方法中调整顶部的距离

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

fileprivate var refreshState: JKRefreshState = .normal {

didSet{

switch refreshState {

case .refreshing:

var inset = self.superView.contentInset

inset.top = inset.top + RefreshingStayHeight

DispatchQueue.main.async {

UIView.animate(withDuration: RefreshControlHideDuration, animations: {

self.superView.contentInset = inset

self.superView.setContentOffset(CGPoint(x: 0, y: -inset.top), animated: false)

}, completion: { (_) in

})

}

default:

break

}

}

}

关于为什么需要使用主队列异步去调整,并且还要设置 contentOffset 请见文章:你的下拉刷新是否“抖”了一下 (在模拟器上有时候不太好使,在真机上没有问题)

8. 刷新中控件转圈效果的实现

松开就可刷新状态刷新中状态的效果可以分成三部分:

  • J 底部的 1/4 圆慢慢变成整圆
  • J 的上面部分竖线慢慢变短
  • 变成整圆之后进行旋转

三部分效果分别如下:

只要将这三种效果合成一种效果就能实现即刻的效果,所以在 drawInLayer 方法中,判断如果是刷新中状态的话,就去执行 layer 的动画,代码如下:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

fileprivate func drawInLayer() {

let startAngle = CGFloat(M_PI) / 2

let endAngle: CGFloat = 0

if refreshState == .refreshing {

if isRefreshingAnim {

return

}

isRefreshingAnim = true

bgGrayLayer.path = nil

let bottomPath = UIBezierPath(arcCenter: DrawCenter, radius: InnerRadius + LineWidth * 0.5, startAngle: 0, endAngle: CGFloat(M_PI) * 2 - 0.1, clockwise: true)

bottomLayer.path = bottomPath.cgPath

let bottomAnim = CABasicAnimation(keyPath: "strokeEnd")

bottomAnim.fromValue = NSNumber(value: 0.25)

bottomAnim.toValue = NSNumber(value: 1.0)

bottomAnim.duration = 0.15

bottomLayer.add(bottomAnim, forKey: nil)

let topPath = UIBezierPath()

topPath.lineCapStyle = .square

topPath.move(to: CGPoint(x: DrawCenter.x + InnerRadius + LineWidth * 0.5, y: DrawCenter.y))

topPath.addLine(to: CGPoint(x: DrawCenter.x + InnerRadius + LineWidth * 0.5, y: DrawCenter.y - (contentOffsetScale - 0.5) * 2 * LineHeight))

topLayer.path = topPath.cgPath

let topAnim = CABasicAnimation(keyPath: "strokeEnd")

topAnim.fromValue = NSNumber(value: 1)

topAnim.toValue = NSNumber(value: 0)

topAnim.duration = 0.15

topLayer.strokeEnd = 0;

topLayer.add(topAnim, forKey: nil)

DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.15, execute: {

let bottomPath = UIBezierPath(arcCenter: DrawCenter, radius: InnerRadius + LineWidth * 0.5, startAngle: 0, endAngle: CGFloat(M_PI) * 2 - 0.1, clockwise: true)

self.bottomLayer.path = bottomPath.cgPath

let bottomAnim = CABasicAnimation(keyPath: "transform.rotation.z")

bottomAnim.fromValue = NSNumber(value: 0)

bottomAnim.toValue = NSNumber(value: 2 * M_PI)

bottomAnim.duration = 0.5

bottomAnim.repeatCount = MAXFLOAT

self.bottomLayer.add(bottomAnim, forKey: "runaroundAnim")

})

return

}

...

}

以上代码中的 isRefreshingAnim 是用来记录当前是否正在执行刷新动画的属性,防止用户在刷新过程中来回拖动 scrollView 造成重复添加动画,代码定义为:

1

2

fileprivate var isRefreshingAnim: Bool = false

运行测试,效果如图:

9. 添加进入刷新中的监听事件

仿照系统的 UIRefreshControl 添加刷新的事件,所以我们可以将我们的刷新控件继承于 UIControl,那么我们的控件就拥有了添加事件的功能:

1

2

3

class JKRefreshControl: UIControl {

...

}

控制器 中给刷新控件添加监听事件,并指定 event.valueChanged

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

override func viewDidLoad() {

super.viewDidLoad()

let refresh = JKRefreshControl()

refresh.addTarget(self, action: #selector(loadData), for: .valueChanged)

scrollView.addSubview(refresh)

}

private func loadData() {

DispatchQueue.global().async {

Thread.sleep(forTimeInterval: 3)

DispatchQueue.main.async {

print("刷新完毕, reload tableView")

}

}

}

刷新控件 的状态被改变成 refresh 状态的话调用监听的方法:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

case .refreshing:

var inset = self.superView.contentInset

inset.top = inset.top + RefreshingStayHeight

DispatchQueue.main.async {

UIView.animate(withDuration: RefreshControlHideDuration, animations: {

self.superView.contentInset = inset

self.superView.setContentOffset(CGPoint(x: 0, y: -inset.top), animated: false)

}, completion: { (_) in

self.sendActions(for: .valueChanged)

})

}

运行测试,可以看到当松手 3 秒后,就会打印 刷新完毕, 但是刷新完毕之后,刷新控件并没有回到最初的位置(被导航栏盖住),所以接下来需要实现的当刷新完毕之后的效果。

10. 刷新完毕动画效果实现

当刷新完毕之后,转圈圆环的 layer 会慢慢变细,具体见顶部的效果图,所以我们也可以类似于系统的刷新控件 UIRefreshControl 提供结束刷新的方法 endRefreshing

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

func endRefreshing() {

let animation = CABasicAnimation(keyPath: "lineWidth")

animation.toValue = 0

animation.duration = 0.5

bottomLayer.lineWidth = 0

bottomLayer.add(animation, forKey: nil)

var inset = self.superView.contentInset

inset.top = inset.top - RefreshingStayHeight

UIView.animate(withDuration: RefreshControlHideDuration, animations: {

self.superView.contentInset = inset

self.superView.setContentOffset(CGPoint(x: 0, y: -inset.top), animated: false)

}, completion: { (_) in

self.refreshState = .normal

})

}

在所有动画执行完毕之后将状态设置为 normal,并且在 normal 是重置一些必要的属性:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

fileprivate var refreshState: JKRefreshState = .normal {

didSet {

switch refreshState {

case .refreshing:

...

case .normal:

bottomLayer.path = nil

topLayer.path = nil

bottomLayer.removeAllAnimations()

topLayer.strokeEnd = 1

bottomLayer.lineWidth = LineWidth

isRefreshingAnim = false

default:

break

}

}

}

在控制器中数据加载完成之后调用 endRefreshing 方法:

1

2

3

4

5

6

7

8

9

10

private func loadData() {

DispatchQueue.global().async {

Thread.sleep(forTimeInterval: 3)

DispatchQueue.main.async {

print("刷新完毕, reload tableView")

self.refresh.endRefreshing()

}

}

}

写到此,基本功能效果已经实现,效果如图:

11. 添加主动进入刷新状态的方法

在<即刻>中,停留在发现页面的时候,点击底部 tabBar 发现 按钮,会主动进行刷新状态,要实现这个功能,只需要添加一个方法 `` 让外界主动调用即可,代码实现如下:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

func beginRefreshing() {

if isBeginRefreshing {

return

}

isBeginRefreshing = true

let contentInsetY = superView.contentInset

UIView.animate(withDuration: 0.25, animations: {

self.superView.setContentOffset(CGPoint(x: 0, y: -contentInsetY.top - RefreshingStayHeight), animated: false)

}) { (_) in

self.refreshState = .refreshing

self.drawInLayer()

}

}

上面代码中的 isBeginRefreshing 是定义一个标志,用于判断用户在第1次触发刷新状态之后,还刷新完成的情况下,再次触发,代码定义为:

1

2

3

4

5

6

7

8

9

10

11

fileprivate var isBeginRefreshing: Bool = false

```

并且在 `refreshState` 设置为 `normal` 状态的时候重置:

```swift

case .normal:

...

isBeginRefreshing = false

在控制器中添加测试代码:

1

2

3

4

5

6

7

8

9

override func viewDidLoad() {

super.viewDidLoad()

let refresh = JKRefreshControl()

refresh.addTarget(self, action: #selector(loadData), for: .valueChanged)

scrollView.addSubview(refresh)

navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Refresh", style: UIBarButtonItemStyle.plain, target: refresh, action: #selector(JKRefreshControl.beginRefreshing))

}

运行测试,效果如图:

小结

其实如果你看到这一句话的话,那么我猜可能存在两种情况:

  1. 你很耐心的居然看完了?
  2. 你是看了开头中间没看,直接想滚动到最底部找 Demo 链接的…如果我猜对了,请给个 star 支持哈哈

GitHub:JKRefreshControl by: EnjoySR

首页 - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-05-02 16:56
浙ICP备14020137号-1 $访客地图$