SwiftUI:用ScrollViewReader和DragGesture橋接UIKit

在推出SwiftUI時,我們是不能控制ScrollView的偏移量offset的:在Xcode12和iOS14中,我們可以使用ScrollViewReader來解決這個問題。
另一個特性是將sectionIndexTitles添加到了List,這是放置在List側(cè)邊的索引列表(例如AZ),用于快速跳轉(zhuǎn)到特定部分。
在這篇文章中,我們使用ScrollViewReaderDragGesture來實現(xiàn)我們自定義的SectionIndexTitles。

preview.gif

這個屏幕中進行了很多操作:讓我們逐個構(gòu)建每個組件。

List

listonly.gif
  • 我們將使用一個ScrollViewScrollViewReader()
  • 雖然我們使用了一個ScrollView,但我們?nèi)匀幌胍覀兊腢I元素使用懶加載,這個時候我們可以使用LazyVstack
  • 最后,設(shè)備數(shù)據(jù)是字典類型,其中keys是section headers,vaules是section content.
let database: [String: [String]] = [

"iPhone": [

"iPhone", "iPhone 3G", "iPhone 3GS", "iPhone 4", "iPhone 4S", "iPhone 5", "iPhone 5C", "iPhone 5S", "iPhone 6", "iPhone 6 Plus", "iPhone 6S", "iPhone 6S Plus", "iPhone SE", "iPhone 7", "iPhone 7 Plus", "iPhone 8", "iPhone 8 Plus", "iPhone X", "iPhone Xs", "iPhone Xs Max", "iPhone X?", "iPhone 11", "iPhone 11 Pro", "iPhone 11 Pro Max", "iPhone SE 2"

],

"iPad": [

"iPad", "iPad 2", "iPad 3", "iPad 4", "iPad 5", "iPad 6", "iPad 7", "iPad Air", "iPad Air 2", "iPad Air 3", "iPad Mini", "iPad Mini 2", "iPad Mini 3", "iPad Mini 4", "iPad Mini 5", "iPad Pro 9.7-inch", "iPad Pro 10.5-inch", "iPad Pro 11-inch", "iPad Pro 11-inch 2", "iPad Pro 12.9-inch", "iPad Pro 12.9-inch 2", "iPad Pro 12.9-inch 3", "iPad Pro 12.9-inch 4"

],

"iPod": [

"iPod Touch", "iPod Touch 2", "iPod Touch 3", "iPod Touch 4", "iPod Touch 5", "iPod Touch 6"

],

"Apple TV": [

"Apple TV 2", "Apple TV 3", "Apple TV 4", "Apple TV 4K"

],

"Apple Watch": [

"Apple Watch", "Apple Watch Series 1", "Apple Watch Series 2", "Apple Watch Series 3", "Apple Watch Series 4", "Apple Watch Series 5"

],

"HomePod": [

"HomePod"

]

]
struct ContentView: View {
  let devices: [String: [String]] = database

  var body: some View {
    ScrollView {
      LazyVStack {
        devicesList
      }
    }
    .navigationBarTitle("Apple Devices")
  }

  var devicesList: some View {
    ForEach(devices.sorted(by: { (lhs, rhs) -> Bool in
      lhs.key < rhs.key
    }), id: \.key) { categoryName, devicesArray in
      Section(
        header: HeaderView(title: categoryName)
      ) {
        ForEach(devicesArray, id: \.self) { deviceName in
          RowView(text: deviceName)
        }
      }
    }
  }
}

在這里還有幾個輔助視圖RowViewHeaderView,具體代碼如下:

struct HeaderView: View {
  let title: String

  var body: some View {
    Text(title)
      .font(.title)
      .fontWeight(.bold)
      .padding()
      .frame(maxWidth: .infinity, alignment: .leading)
  }
}

struct RowView: View {
  let text: String

  var body: some View {
    Text(text)
      .padding()
      .frame(maxWidth: .infinity, alignment: .leading)
  }
}

Section Index Titles

sectiontitles.gif

這是另一個完全獨立的視圖。為了讓它更有趣,我用SF Symbols代替text文本:

struct SectionIndexTitles: View {
  let titles: [String]

  var body: some View {
    VStack {
      ForEach(titles, id: \.self) { title in
        SectionIndexTitle(image: sfSymbol(for: title))
      }
    }
  }

  func sfSymbol(for deviceCategory: String) -> Image {
    let systemName: String
    switch deviceCategory {
    case "iPhone": systemName = "iphone"
    case "iPad": systemName = "ipad"
    case "iPod": systemName = "ipod"
    case "Apple TV": systemName = "appletv"
    case "Apple Watch": systemName = "applewatch"
    case "HomePod": systemName = "homepod"
    default: systemName = "xmark"
    }
    return Image(systemName: systemName)
  }
}

和前面一樣,我引入了一個新的視圖SectionIndexTitle來提高可讀性.

struct SectionIndexTitle: View {
  let image: Image

  var body: some View {
    RoundedRectangle(cornerRadius: 8, style: .continuous)
      .foregroundColor(Color.gray.opacity(0.1))
      .frame(width: 40, height: 40)
      .overlay(
        image
          .foregroundColor(.blue)
      )
  }
}

視圖混合

現(xiàn)在我們有了devices listSectionIndexTitles,我們可以把它們疊加放在一起:

struct ContentView: View {
  ...

  var body: some View {
    ScrollView {
      LazyVStack {
        devicesList
      }
    }
    .overlay(sectionIndexTitles)
    .navigationBarTitle("Apple Devices")
  }

  ...

  var sectionIndexTitles: some View {
    SectionIndexTitles(titles: devices.keys.sorted())
      .frame(maxWidth: .infinity, alignment: .trailing)
      .padding()
  }
}

我們也可以使用ZStack,但是我們希望我們的SectionIndexTitlesScrollView的頂部,并避免標題擴展到ScrollView本身之外。

ScrollViewReader

使用ScrollViewReader組件.
ScrollView包裝到ScrollViewReader中,我們得到了一個ScrollViewProxy實例,用于以編程方式觸發(fā)滾動:
這是通過調(diào)用實例上的scrollTo(_:)方法,并傳遞我們希望滾動到的視圖的id來實現(xiàn)的。

注意,我們想要滾動到的元素可能還沒有加載:ScrollViewProxy仍然會按預(yù)期執(zhí)行。

struct ContentView: View {
  ...

  var body: some View {
    ScrollViewReader { proxy in
      ScrollView {
        LazyVStack {
          devicesList
        }
      }
      .overlay(sectionIndexTitles)
    }
    .navigationBarTitle("Apple Devices")
  }

  ...
}

ScrollViewProxy

在我們的嘗試中,我們把每個section title作為一個button跳轉(zhuǎn)到對應(yīng)的section,為此我們需要這樣做:

  • 把proxy傳到SectionIndexTitles
  • SectionIndexTitle包裝到button中,以便出發(fā)事件后滾動到對應(yīng)的section
struct SectionIndexTitles: View {
  let proxy: ScrollViewProxy
  let titles: [String]

  var body: some View {
    VStack {
      ForEach(titles, id: \.self) { title in
        Button {
          proxy.scrollTo(title)
        } label: {
          SectionIndexTitle(image: sfSymbol(for: title))
        }
      }
    }
  }

  ...
}

這兩個步驟將使我們的SectionIndexTitles工作:
我們不需要在ScrollView的sections中添加顯式的.id修飾符,因為我們的devicesList是通過ForEach定義的,其中每個視圖都有一個隱式標識符id: \.key(在ForEach中設(shè)置),它等于我們的設(shè)備類別(device categorie)。

button.gif

DragGesture

雖然上面的可以運行了,但是它并沒有完全模仿UITableViewsectionIndexTitles:
sectionIndexTitles的工作方式是用手指在標題上拖動,也會讓tableView滾動到右邊對應(yīng)section

第二步我們將在整個SectionIndexTitles添加拖拽手勢,然后當手指在其中一個索引標題上時將出發(fā)右邊的scrollTo動作.

方法步驟:

  • 存儲全局的dragLocation在一個@GestureState變量中
  • 給每個SectionIndexTitle中添加一個拖動位置變化的監(jiān)聽,當手勢發(fā)生變化時,觸發(fā)scrollTo動作
struct SectionIndexTitles: View {
  let proxy: ScrollViewProxy
  let titles: [String]
  @GestureState private var dragLocation: CGPoint = .zero

  var body: some View {
    VStack {
      ForEach(titles, id: \.self) { title in
        SectionIndexTitle(image: sfSymbol(for: title))
          .background(dragObserver(title: title))
      }
    }
    .gesture(
      DragGesture(minimumDistance: 0, coordinateSpace: .global)
        .updating($dragLocation) { value, state, _ in
          state = value.location
        }
    )
  }

  func dragObserver(title: String) -> some View {
    GeometryReader { geometry in
      dragObserver(geometry: geometry, title: title)
    }
  }

  // This function is needed as view builders don't allow to have 
  // pure logic in their body.
  private func dragObserver(geometry: GeometryProxy, title: String) -> some View {
    if geometry.frame(in: .global).contains(dragLocation) {
      // we need to dispatch to the main queue because we cannot access to the
      // `ScrollViewProxy` instance while the body is rendering
      DispatchQueue.main.async {
        proxy.scrollTo(title, anchor: .center)
      }
    }
    return Rectangle().fill(Color.clear)
  }

  ...
}

有了這些,我們就實現(xiàn)了我們要的!??


preview.gif

結(jié)論

SwiftUI并沒有提供UIKit所擁有的一切,可能永遠也不會,但這不應(yīng)該阻止我們嘗試并提出我們自己的SwiftUI解決方案,讓我們更容易遷移到SwiftUI。

我個人真的很喜歡上面的例子,因為現(xiàn)在我們的SectionIndexTitles只是另一個SwiftUI視圖,這使得進一步定制它變得非常容易(如果SF Symbols對你來說還不夠好),而這在UIKit中是不可能的。

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

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

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