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

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

- 我們將使用一個
ScrollView和ScrollViewReader() - 雖然我們使用了一個
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)
}
}
}
}
}
在這里還有幾個輔助視圖RowView和HeaderView,具體代碼如下:
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

這是另一個完全獨立的視圖。為了讓它更有趣,我用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 list和SectionIndexTitles,我們可以把它們疊加放在一起:
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,但是我們希望我們的SectionIndexTitles在ScrollView的頂部,并避免標題擴展到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)。

DragGesture
雖然上面的可以運行了,但是它并沒有完全模仿UITableView的sectionIndexTitles:
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)了我們要的!??

結(jié)論
SwiftUI并沒有提供UIKit所擁有的一切,可能永遠也不會,但這不應(yīng)該阻止我們嘗試并提出我們自己的SwiftUI解決方案,讓我們更容易遷移到SwiftUI。
我個人真的很喜歡上面的例子,因為現(xiàn)在我們的SectionIndexTitles只是另一個SwiftUI視圖,這使得進一步定制它變得非常容易(如果SF Symbols對你來說還不夠好),而這在UIKit中是不可能的。