SwiftUI - How to add "letters sections" and alphabet jumper in a Form?

Viewed 1938

How can I make a Form in which the elements are automatically divided into sections based on their first letter and add to the right the alphabet jumper to show the elements starting by the selected letter (just like the Contacts app)?

I also noted a strange thing that I have no idea how to recreate: not all letters are shown, some of them appear as "•". However, when you tap on them, they take you to the corresponding letter anyway. I tried using a ScrollView(.vertical) inside a ZStack and adding .scrollTo(selection) into the action of the Buttons, however 1) It didn't scroll to the selection I wanted 2) When I tapped on the "•", it was as if I was tapping on all of them because they all did the tapping animation 3) I wasn't able to divide the List as I wanted to. I have this:

import SwiftUI

struct ContentView: View {

let alphabet = ["A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W", "X","Y", "Z"]
let values = ["Avalue", "Bvalue", "Cvalue", "Dvalue"]

var body: some View {
           ScrollViewReader{ scrollviewr in
               ZStack {
                   ScrollView(.vertical) {
                       VStack {
                           ForEach(alphabet, id: \.self) { letters in
                               Button(letters){
                                   withAnimation {
                                    scrollviewr.scrollTo(letters)
                                   }
                               }
                           }
                       }
                   }.offset(x: 180, y: 120)

                VStack {
                    
                ForEach(values, id: \.self){ vals in
                               Text(vals).id(vals)
                   }
                }
               }
           }
   }
}

But I'd want it like this:

enter image description here

2 Answers
import SwiftUI

struct AlphabetSort2: View {
    let alphabet = ["A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W", "X","Y", "Z"]
    let values = ["Avalue", "Bvalue", "Cvalue", "Dvalue", "Mvalue", "Zvalue"]
    var body: some View {
        ScrollView {
            ScrollViewReader { value in
                ZStack{
                    List{
                        ForEach(alphabet, id: \.self) { letter in
                            Section(header: Text(letter)) {
                                ForEach(values.filter { $0.hasPrefix(letter) }, id: \.self) { vals in
                                    Text(vals).id(vals)
                                }
                            }.id(letter)
                        }
                    }
                    HStack{
                        Spacer()
                        VStack {
                            ForEach(0..<alphabet.count, id: \.self) { idx in
                                Button(action: {
                                    withAnimation {
                                        value.scrollTo(alphabet[idx])
                                    }
                                }, label: {
                                    Text(idx % 2 == 0 ? alphabet[idx] : "\u{2022}")
                                })
                            }
                        }
                    }
                }
            }
        }
    }
}

struct AlphabetSort2_Previews: PreviewProvider {
    static var previews: some View {
        AlphabetSort2()
    }
}

Add an swipe-up-and-down action for the alphabet jumper, so we can get an UILocalizedIndexCollation -like swipe effect, it works for view and add mode, but delete, I guess it is due to SwiftUI's UI refresh mechanism.

extension String {
    static var alphabeta: [String] {
        var chars = [String]()
        for char in "abcdefghijklmnopqrstuvwxyz#".uppercased() {
            chars.append(String(char))
        }
        return chars
    }
}

struct Document: View {
    @State var items = ["Alpha", "Ash", "Aman", "Alisia", "Beta", "Baum", "Bob", "Bike", "Beeber", "Beff", "Calipha", "Cask", "Calf", "Deamon", "Deaf", "Dog", "Silk", "Seal", "Tiger", "Tom", "Tan", "Tint", "Urshinabi", "Verizon", "Viber", "Vein", "Wallet", "Warren", "Webber", "Waiter", "Xeon", "Young", "Yoda", "Yoga", "Yoger", "Yellow", "Zeta"]
    var body: some View {
        ScrollViewReader { scrollView in
            HStack {
                List {
                    ForEach(String.alphabeta, id: \.self){ alpha in
                        let subItems = items.filter({$0.starts(with: alpha)})
                        if !subItems.isEmpty {
                            Section(header: Text(alpha)) {
                                ForEach(subItems, id: \.self) { item in
                                    Text(item)
                                }.onDelete(perform: { offsets in
                                    items.remove(at: offsets.first!)
                                })
                            }.id(alpha)
                        }

                    }
                }
                
                VStack{
                    VStack {
                        SectionIndexTitles(proxy: scrollView, titles: retrieveSectionTitles()).font(.footnote)
                    }
                    .padding(.trailing, 10)
                }
            }
        }
//        .navigationBarTitleDisplayMode(.inline)
//        .navigationBarHidden(true)
    }
    
    func retrieveSectionTitles() ->[String] {
        var titles = [String]()
        titles.append("@")
        for item in self.items {
            if !item.starts(with: titles.last!){
                titles.append(String(item.first!))
            }
        }
        titles.remove(at: 0)
        if titles.count>1 && titles.first! == "#" {
            titles.append("#")
            titles.removeFirst(1)
        }
        return titles
    }
}

struct Document_Previews: PreviewProvider {
    static var previews: some View {
        Document()
    }
}

struct SectionIndexTitles: View {
    class IndexTitleState: ObservableObject {
        var currentTitleIndex = 0
        var titleSize: CGSize = .zero
    }
    
    let proxy: ScrollViewProxy
    let titles: [String]
    @GestureState private var dragLocation: CGPoint = .zero
    @StateObject var indexState = IndexTitleState()
    
    var body: some View {
        VStack {
            ForEach(titles, id: \.self) { title in
                Text(title)
                    .foregroundColor(.blue)
                    .modifier(SizeModifier())
                    .onPreferenceChange(SizePreferenceKey.self) {
                        self.indexState.titleSize = $0
                    }
                    .onTapGesture {
                        proxy.scrollTo(title, anchor: .top)
                    }
            }
        }
        .gesture(
            DragGesture(minimumDistance: indexState.titleSize.height, coordinateSpace: .named(titles.first))
                .updating($dragLocation) { value, state, _ in
                    state = value.location
                    scrollTo(location: state)
                }
        )
    }
    
    private func scrollTo(location: CGPoint){
        if self.indexState.titleSize.height > 0{
            let index = Int(location.y / self.indexState.titleSize.height)
            if index >= 0 && index < titles.count {
                if indexState.currentTitleIndex != index {
                    indexState.currentTitleIndex = index
                    print(titles[index])
                    DispatchQueue.main.async {
                        let impactMed = UIImpactFeedbackGenerator(style: .medium)
                            impactMed.impactOccurred()
//                        withAnimation {
                            proxy.scrollTo(titles[indexState.currentTitleIndex], anchor: .top)
//                        }
                    }
                }
            }
        }
    }
    
}

struct SizePreferenceKey: PreferenceKey {
    static var defaultValue: CGSize = .zero

    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        value = nextValue()
    }
}

struct SizeModifier: ViewModifier {
    private var sizeView: some View {
        GeometryReader { geometry in
            Color.clear.preference(key: SizePreferenceKey.self, value: geometry.size)
        }
    }

    func body(content: Content) -> some View {
        content.background(sizeView)
    }
}
Related