SwiftUI: ScrollView offset

在线搜索“SwiftUI ScrollView offset”会得到很多关于如何控制ScrollView滚动位置的讨论:随着iOS 14的发布,SwiftUI新增了ScrollViewReader.

这是否意味着我们不再需要ScrollView偏移量offset?
在本文中,我们将探讨如何获得offset偏移量以及它的一些用途.

ScrollView offset

类似UIScrollView,ScrollView由两个layer组成:

  • frame layer在视图结构中定位ScrollView
  • content layer所有子组件存放的容器

如果我们查看一个垂直滚动视图(本文将使用这个视图),则偏移量表示frame layer层的y坐标的最小值与content layer内容层的y坐标的最小值之间的差值。

获取 offset

SwiftUI的ScrollView初始化方法:

public struct ScrollView<Content: View>: View {
  ...
  public init(
    _ axes: Axis.Set = .vertical, 
    showsIndicators: Bool = true, 
    @ViewBuilder content: () -> Content
  )
}

除了content视图构建器之外,我们没有什么可以使用的.
让我们创建一个简单的ScrollView的例子,用一些Text文本填充:

ScrollView {
  Text("A")
  Text("B")
  Text("C")
}

偏移量将与内容中第一个元素Text("A")的偏移量相同,我们如何得到这个元素的偏移量?
再一次,我们需要用到SwiftUI的GeometryReader,以及一个新的PreferenceKey

首先,让我们定义preference key:

private struct OffsetPreferenceKey: PreferenceKey {
  static var defaultValue: CGFloat = .zero
  static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}

其次,我们为视图的.background修饰器添加GeometryReader:

ScrollView {
  Text("A")
    .background(
      GeometryReader { proxy in
        Color.clear
          .preference(
            key: OffsetPreferenceKey.self,
            value: proxy.frame(in: .local).minY
          )
      }
    )
  Text("B")
  Text("C")
}

geometry reader就像我们在SwiftUI:GeometryReader中看到的一样,是用来分享视图层次结构中元素的信息:我们使用它来提取视图的y坐标的最小值,计算出偏移量。

然而它并不能正常执行:
我们正在为局部坐标空间中的框架查询GeometryProxy,该空间是我们的.background背景视图中建议的空间。
简而言之,就是Color.clearminY.local局部坐标一直是0.
修改为.global全局坐标,从设备屏幕的坐标系来看是有问题的,Scrollview可以放在视图层次结构的任何地方,.global全局坐标系并没有什么帮助。

如果我们把GeometryReader放在Text("A")上面会发生什么?

ScrollView {
  GeometryReader { proxy in
    Color.clear
      .preference(
        key: OffsetPreferenceKey.self,
        value: proxy.frame(in: .local).minY
      )
  }
  Text("A")
  Text("B")
  Text("C")
}

这可能看起来更有希望,但它仍然不会工作:
在这种情况下,.local的坐标系是ScrollViewcontent layer,但是我们需要把它显示在ScrollViewframe layer

根据我们的ScrollViewframe layer获得到GeometryProxy,我们需要在ScrollView上定义一个新的坐标空间,并在GeometryReader中引用它:

ScrollView {
  Text("A")
    .background(
      GeometryReader { proxy in
        Color.clear
          .preference(
            key: OffsetPreferenceKey.self,
            value: proxy.frame(in: .named("frameLayer")).minY
          )
      }
    )
  Text("B")
  Text("C")
}
.coordinateSpace(name: "frameLayer") // the new coordinate space!

这是可行的,因为ScrollViewframe layer暴露在的外层。现在正确的ScrollViewoffset偏移量在视图层次结构中可用。

func offset(_ proxy:GeometryProxy) -> some View {
        let minY = proxy.frame(in: .named("frameLayer")).minY
        print("minY:\(minY)")
        return Color.clear
    }
    
    var body: some View {
        ScrollView {
          Text("A")
            .background(
                GeometryReader { proxy in
                    self.offset(proxy)
              }
            )
          Text("B")
          Text("C")
            Spacer().frame(maxWidth: .infinity)
        }
        .background(Color.orange)
        .coordinateSpace(name: "frameLayer")
    }

简单修改下,在控制台看下结果!!

创建ScrollViewOffset View

我们在开发中需要抽取封装,可以在需要时轻松地获得偏移量。
ScrollView接受content内容视图构建器,这使得我们无法获得该内容的第一个元素(如果你知道方法,请联系我).

我们可以申请.background修饰器作用于整个content上,但是这并没有考虑到content内容本身可能是一个Group组的可能性,在这种情况下,修饰符将应用于组的每个元素,这不是我们想要的。

一种解决方案是将geometry reader移动到ScrollView内容的上方,然后在实际内容上用负的padding来隐藏它:

struct ScrollViewOffset<Content: View>: View {
  let content: () -> Content

  init(@ViewBuilder content: @escaping () -> Content) {
    self.content = content
  }

  var body: some View {
    ScrollView {
      offsetReader
      content()
        .padding(.top, -8)
      // 👆🏻 this places the real content as if our `offsetReader` was 
      // not there.
    }
    .coordinateSpace(name: "frameLayer")
  }

  var offsetReader: some View {
    GeometryReader { proxy in
      Color.clear
        .preference(
          key: OffsetPreferenceKey.self,
          value: proxy.frame(in: .named("frameLayer")).minY
        )
    }
    .frame(height: 0) 
    // this makes sure that the reader doesn't affect the content height
  }
}

类似于readSize修饰器,我们也可以让ScrollViewOffset在每次偏移量改变时触发回调方法:

struct ScrollViewOffset<Content: View>: View {
  let onOffsetChange: (CGFloat) -> Void
  let content: () -> Content

  init(
    onOffsetChange: @escaping (CGFloat) -> Void,
    @ViewBuilder content: @escaping () -> Content
  ) {
    self.onOffsetChange = onOffsetChange
    self.content = content
  }

  var body: some View {
    ScrollView {
      offsetReader
      content()
        .padding(.top, -8)
    }
    .coordinateSpace(name: "frameLayer")
    .onPreferenceChange(OffsetPreferenceKey.self, perform: onOffsetChange)
  }

  var offsetReader: some View {
    GeometryReader { proxy in
      Color.clear
        .preference(
          key: OffsetPreferenceKey.self,
          value: proxy.frame(in: .named("frameLayer")).minY
        )
    }
    .frame(height: 0)
  }
}

然后我们就可以这样使用:

ScrollViewOffset { offset in
  print("New ScrollView offset: \(offset)") 
} content: {
  Text("A")
  Text("B")
  Text("C")
}

用法

现在我们有了这个强大的组件,就可以做我们要做的了。
最常见的用法可能是在滚动时改变顶部安全区域的颜色:


status.gif
struct ContentView: View {
  @State private var scrollOffset: CGFloat = .zero

  var body: some View {
    ZStack {
      scrollView
      statusBarView
    }
  }

  var scrollView: some View {
    ScrollViewOffset {
      scrollOffset = $0
    } content: {
      LazyVStack {
        ForEach(0..<100) { index in
          Text("\(index)")
        }
      }
    }
  }

  var statusBarView: some View {
    GeometryReader { geometry in
      Color.red
        .opacity(opacity)
        .frame(height: geometry.safeAreaInsets.top, alignment: .top)
        .edgesIgnoringSafeArea(.top)
    }
  }

  var opacity: Double {
    switch scrollOffset {
    case -100...0:
      return Double(-scrollOffset) / 100.0
    case ...(-100):
      return 1
    default:
      return 0
    }
  }
}

这是一个基于滚动位置改变背景颜色的视图:


rainbow.gif
struct ContentView: View {
  @State var scrollOffset: CGFloat = .zero

  var body: some View {
    ZStack {
      backgroundColor
      scrollView
    }
  }

  var backgroundColor: some View {
    Color(
      //         This number determines how fast the color changes 👇🏻
      hue: Double(abs(scrollOffset.truncatingRemainder(dividingBy: 3500))) / 3500,
      saturation: 1,
      brightness: 1
    )
    .ignoresSafeArea()
  }

  var scrollView: some View {
    ScrollViewOffset {
      scrollOffset = $0
    } content: {
      LazyVStack(spacing: 8) {
        ForEach(0..<100) { index in
          Text("\(index)")
            .font(.title)
        }
      }
    }
  }
}

truncatingRemainder(dividingBy:)

浮点数取余:商取整数,余数还是浮点数
类似整型的%,

let value1 = 5.5
let value2 = 2.2
let div = value1.truncatingRemainder(dividingBy: value2)
//div=1.1
//即商是2,余数为1.1。

iOS13 vs iOS14

我们在ios14上看到的一切都很好,但是在ios13上,最初的偏移量是不同的。

在iOS13中,偏移量考虑了顶部安全区域:例如,嵌入大标题的NavigationView中的ScrollViewOffset的初始偏移量为140,iOS14中的相同视图的初始(正确)偏移量值为0
这点是需要特别注意的!!!

结论

有了ScrollViewReader,在大多数用例中,我们不再需要访问ScrollView偏移量:对于其余的用例,GeometryReader都是可以做到的.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,185评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,652评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,524评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,339评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,387评论 6 391
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,287评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,130评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,985评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,420评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,617评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,779评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,477评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,088评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,716评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,857评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,876评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,700评论 2 354

推荐阅读更多精彩内容