SwiftUI: ScrollView offset

在線搜索“SwiftUI ScrollView offset”會(huì)得到很多關(guān)于如何控制ScrollView滾動(dòng)位置的討論:隨著iOS 14的發(fā)布,SwiftUI新增了ScrollViewReader.

這是否意味著我們不再需要ScrollView偏移量offset?
在本文中,我們將探討如何獲得offset偏移量以及它的一些用途.

ScrollView offset

類似UIScrollView,ScrollView由兩個(gè)layer組成:

  • frame layer在視圖結(jié)構(gòu)中定位ScrollView
  • content layer所有子組件存放的容器

如果我們查看一個(gè)垂直滾動(dòng)視圖(本文將使用這個(gè)視圖),則偏移量表示frame layer層的y坐標(biāo)的最小值與content layer內(nèi)容層的y坐標(biāo)的最小值之間的差值。

獲取 offset

SwiftUI的ScrollView初始化方法:

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

除了content視圖構(gòu)建器之外,我們沒有什么可以使用的.
讓我們創(chuàng)建一個(gè)簡(jiǎn)單的ScrollView的例子,用一些Text文本填充:

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

偏移量將與內(nèi)容中第一個(gè)元素Text("A")的偏移量相同,我們?nèi)绾蔚玫竭@個(gè)元素的偏移量?
再一次,我們需要用到SwiftUI的GeometryReader,以及一個(gè)新的PreferenceKey

首先,讓我們定義preference key:

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

其次,我們?yōu)橐晥D的.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就像我們?cè)?a href="http://www.itdecent.cn/p/bb7005502299" target="_blank">SwiftUI:GeometryReader中看到的一樣,是用來分享視圖層次結(jié)構(gòu)中元素的信息:我們使用它來提取視圖的y坐標(biāo)的最小值,計(jì)算出偏移量。

然而它并不能正常執(zhí)行:
我們正在為局部坐標(biāo)空間中的框架查詢GeometryProxy,該空間是我們的.background背景視圖中建議的空間。
簡(jiǎn)而言之,就是Color.clearminY.local局部坐標(biāo)一直是0.
修改為.global全局坐標(biāo),從設(shè)備屏幕的坐標(biāo)系來看是有問題的,Scrollview可以放在視圖層次結(jié)構(gòu)的任何地方,.global全局坐標(biāo)系并沒有什么幫助。

如果我們把GeometryReader放在Text("A")上面會(huì)發(fā)生什么?

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

這可能看起來更有希望,但它仍然不會(huì)工作:
在這種情況下,.local的坐標(biāo)系是ScrollViewcontent layer,但是我們需要把它顯示在ScrollViewframe layer。

根據(jù)我們的ScrollViewframe layer獲得到GeometryProxy,我們需要在ScrollView上定義一個(gè)新的坐標(biāo)空間,并在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!

這是可行的,因?yàn)?code>ScrollView把frame layer暴露在的外層?,F(xiàn)在正確的ScrollViewoffset偏移量在視圖層次結(jié)構(gòu)中可用。

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")
    }

簡(jiǎn)單修改下,在控制臺(tái)看下結(jié)果??!

創(chuàng)建ScrollViewOffset View

我們?cè)陂_發(fā)中需要抽取封裝,可以在需要時(shí)輕松地獲得偏移量。
ScrollView接受content內(nèi)容視圖構(gòu)建器,這使得我們無法獲得該內(nèi)容的第一個(gè)元素(如果你知道方法,請(qǐng)聯(lián)系我).

我們可以申請(qǐng).background修飾器作用于整個(gè)content上,但是這并沒有考慮到content內(nèi)容本身可能是一個(gè)Group組的可能性,在這種情況下,修飾符將應(yīng)用于組的每個(gè)元素,這不是我們想要的。

一種解決方案是將geometry reader移動(dòng)到ScrollView內(nèi)容的上方,然后在實(shí)際內(nèi)容上用負(fù)的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在每次偏移量改變時(shí)觸發(fā)回調(diào)方法:

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")
}

用法

現(xiàn)在我們有了這個(gè)強(qiáng)大的組件,就可以做我們要做的了。
最常見的用法可能是在滾動(dòng)時(shí)改變頂部安全區(qū)域的顏色:


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
    }
  }
}

這是一個(gè)基于滾動(dòng)位置改變背景顏色的視圖:


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:)

浮點(diǎn)數(shù)取余:商取整數(shù),余數(shù)還是浮點(diǎn)數(shù)
類似整型的%,

let value1 = 5.5
let value2 = 2.2
let div = value1.truncatingRemainder(dividingBy: value2)
//div=1.1
//即商是2,余數(shù)為1.1。

iOS13 vs iOS14

我們?cè)趇os14上看到的一切都很好,但是在ios13上,最初的偏移量是不同的。

在iOS13中,偏移量考慮了頂部安全區(qū)域:例如,嵌入大標(biāo)題的NavigationView中的ScrollViewOffset的初始偏移量為140,iOS14中的相同視圖的初始(正確)偏移量值為0
這點(diǎn)是需要特別注意的?。?!

結(jié)論

有了ScrollViewReader,在大多數(shù)用例中,我們不再需要訪問ScrollView偏移量:對(duì)于其余的用例,GeometryReader都是可以做到的.

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容