这一节开始,我们来实现支持下拉刷新的滚动视图,它定义在 BxRefreshableScrollView.swift 里。

SymbolView

先从最简单的部分开始:SymbolView,它表示开始下拉后,在 ScrollView 顶部表示下拉幅度的图标。它有四个属性:

struct SymbolView: View {
  var height: CGFloat
  var loading: Bool
  var frozen: Bool
  var rotation: Angle
}

其中:

  • height 表示这部分区域的高度,因为我们肯定不能随着 ScrollView 不断下拉一直拉伸这块区域,拉到一定份上,我们就应该固定它,然后进入加载数据的环节了;
  • loading 表示 ScrollView 是否正在加载数据,如果是,就显示上一节我们定义的 ActivityIndicator
  • frozen 表示是否在 ScrollView 上固定显示这块包含图标的区域;
  • rotation 表示在下拉过程中,表示下拉幅度的图标的旋转角度;

然后,是 body 的部分:

var body: some View {
  Group {
    if loading {
      VStack {
        Spacer()
        ActivityIndicator()
        Spacer()
      }
      .frame(height: height).fixedSize()
      .offset(y: -height + (loading && frozen ? height : 0))
    }
    else {
      Image(systemName: "arrow.down")
        .resizable()
        .aspectRatio(contentMode: .fit)
        .frame(width: height * 0.25, height: height*0.25).fixedSize()
        .padding(height * 0.375)
        .rotationEffect(rotation)
        .offset(y: -height + (loading && frozen ? height : 0))
    }
  }
}

View 自身很简单,根据是否正在加载数据,我们分别显示了 ActivityIndicatorarrow.down 图标。值得说下的,是我们如何把 SymbolView 固定在 ScrollView 顶端的。这是通过下面这两行代码实现的:

.frame(height: height).fixedSize()
.offset(y: -height + (loading && frozen ? height : 0))

首先,frame.fixedSize 把这个 View 的尺寸固定在了 height 高度,让它不随着自己的父容器的尺寸改变。其次,初始状态时,这个 Viewoffset-height。稍后,构建整个滚动视图的时候就会知道,这个尺寸是在屏幕外看不到的。然后,随着下拉,当它完整显示出来之后,offset 就是 0 了。稍后,等实现滚动视图的时候就会看到,我们会让滚动视图配合 SymbolView 的位置,让它们同时显示在屏幕上。

另外,我们还要介绍一个设置 SymbolView 里图标旋转角度的方法:

func symbolRotation(_ scrollOffset: CGFloat) -> Angle {
  if scrollOffset < threshold * 0.6 {
    return .degrees(0)
  }
  else {
    let h = Double(threshold)
    let d = Double(scrollOffset)
    let v = max(min(d - (h * 0.6), h * 0.4), 0)

    return .degrees(180 * v / (h * 0.4))
  }
}

它根据下拉的距离,也就是它的参数 scrollOffset,返回一个 SwiftUI 中的角度对象 Angle。当下拉距离小于我们设定阈值的 60% 之前,都不转动图标(如果你慢慢下拉 Demo,就会发现,有一段距离,箭头是不旋转的)。

超过了这个值之后,我们需要计算的,是继续滑动的距离,也就是 d - (h * 0.6),占 SymbolView 剩余高度的比例。根据这个比例,算出 180 度的占比,这个占比就是 symbolRotation 的返回值了。

MovingView 和 FixedView

接下来,我们来实现一开始说到的 MovingViewFixedView

struct MovingView: View {
  var body: some View {
    GeometryReader {
      Color.clear
        /// Compare to:
        /// ```
        /// .anchorPreference(key: TagPreferenceKey.self,
        ///                   value: .bounds,
        ///                   transform: { [TagPreferenceData(bounds: $0)] })
        /// ```
        .preference(key: RefreshableKey.PrefKey.self,
                    value: [RefreshableKey.PrefData(
                      vType: .movingView,
                      bounds: $0.frame(in: .global))
                    ])
    }
    .frame(height: 0)
  }
}

struct FixedView: View {
  var body: some View {
    GeometryReader {
      Color.clear.preference(
        key: RefreshableKey.PrefKey.self,
        value: [RefreshableKey.PrefData(
          vType: .fixedView,
          bounds: $0.frame(in: .global))
        ])
    }
  }
}

可以看到,这两个 View 都只是个透明背景的“摆设”。MovingView 是个高度为 0 的 View,我们会把它和 ScrollView 的内容顶部对其叠加在一起。而 FixedView 则是一个和 ScrollView 尺寸相同的 View 用于定位滚动内容的初始位置。

在这两个 View 的定义里,我们使用了 GeometryReader 在运行时获取它们的实际尺寸。保存的时候,这里使用了和之前 Tag 不同的方式,我们使用了 preference 方法,它的第一个参数,和之前使用的 anchorPreference 相同,即保存数据的键,因此我们传递了 RefreshableKey.PrefKey.self。但保存值的时候就有差异了,这次,我们无需指定要获取的数据类型,也没有生成数据的 closure,而是要直接写入要保存的数据,也就是 View 的尺寸。

为此,我们可以借助 GeometryProxy。它有一个 frame(in:) 方法。给它传递一个坐标系,它会返回对应的 View 在这个坐标系中的尺寸。由于稍后,我们只是要计算两个 View 的相对距离。因此这里统一设置成了 .global,表示按照屏幕坐标系计算。

What's next?

至此,为了实现下拉刷新,所有的准备工作就都完成了。在继续之前,请大家务必要理解在 MovingViewFixedView 中保存数据的方法。它们不是很困难,就是思维方式以及用法上,我们不是很习惯。因此,如果你刚接触这些内容,我们稍微慢一点,确保一切都 OK 后,再开始下一节的内容。

所有订阅均支持 12 期免息分期

¥ 59

按月订阅

一个月,观看并下载所有视频内容。初来泊学,这可能是个最好的开始。

开始订阅

¥ 512

按年订阅

一年的时间,让我们一起疯狂地狩猎知识吧。比按月订阅优惠 28%

开始订阅

¥ 1280

泊学终身会员

永久观看和下载所有泊学网站视频,并赠送 100 元商店优惠券。

我要加入
如需帮助,欢迎通过以下方式联系我们