Search code examples
listswiftuiscrollviewselectorpicker

SwiftUI - How to make a selectable scrollable list?


I'm trying to make something like this:

before scrolling before scrolling

after scrolling after scrolling

Essentially:

  1. The items are arranged in a scrollable list
  2. The item located at the center is the one selected
  3. The selected item's properties are accessible (by updating @State variables)
  4. Ideally the scroll gesture is "sticky." For example, whichever item closest to the center after scrolling readjusts its position to the center, so that the overall arrangement is the same.

I've tried using a ScrollView but I have no idea of how to implement 2 and 4. I guess the idea is quite similar to a Picker?

I've been stuck on this for a while. Any suggestion would be greatly appreciated. Thanks in advance!


Solution

  • You could detect the location of items to select the centred one.

    // Data model
    struct Item: Identifiable {
        let id = UUID()
        
        var value: Int
        // Other properties...
        var loc: CGRect = .zero
    }
    
    struct ContentView: View {
        @State private var ruler: CGFloat!
        
        @State private var items = (0..<10).map { Item(value: $0) }
        @State private var centredItem: Item!
        
        var body: some View {
            HStack {
                if let item = centredItem {
                    Text("\(item.value)")
                }
                
                HStack(spacing: -6) {
                    Rectangle()
                        .frame(height: 1)
                        .measureLoc { loc in
                            ruler = (loc.minY + loc.maxY) / 2
                        }
                    
                    Image(systemName: "powerplug.fill")
                    
                    ScrollView(.vertical, showsIndicators: false) {
                        VStack(spacing: 0) {
                            ForEach($items) { $item in
                                Text("\(item.value)")
                                    .padding()
                                    .frame(width: 80, height: 80)
                                    .background(centredItem != nil &&
                                                centredItem.id == item.id ? .yellow : .white)
                                    .border(.secondary)
                                    .measureLoc { loc in
                                        item.loc = loc
                                        
                                        if let ruler = ruler {
                                            if item.loc.maxY >= ruler && item.loc.minY <= ruler {
                                                withAnimation(.easeOut) {
                                                    centredItem = item
                                                }
                                            }
                                            
                                            // Move outsides
                                            if ruler <= items.first!.loc.minY ||
                                                ruler >= items.last!.loc.maxY {
                                                withAnimation(.easeOut) {
                                                    centredItem = nil
                                                }
                                            }
                                        }
                                    }
                            }
                        }
                        // Extra space above and below
                        .padding(.vertical, ruler)
                    }
                }
            }
            .padding()
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
    }
    

    Detect location:

    struct LocKey: PreferenceKey {
        static var defaultValue: CGRect = .zero
        
        static func reduce(value: inout CGRect, nextValue: () -> CGRect) {}
    }
    
    extension View {
        func measureLoc(_ perform: @escaping (CGRect) ->()) -> some View {
            overlay(GeometryReader { geo in
                Color.clear
                    .preference(key: LocKey.self, value: geo.frame(in: .global))
            }.onPreferenceChange(LocKey.self, perform: perform))
        }
    }