0

I would like to create a custom segmented controller in SwiftUI, and I found one made from this post. After slightly altering the code and putting it into my ContentView, the colored capsule would not fit correctly.

Here is an example of my desired result:

enter image description here

This is the result when I use it in ContentView:

enter image description here

CustomPicker.swift:

struct CustomPicker: View {
    @State var selectedIndex = 0
    var titles = ["Item #1", "Item #2", "Item #3", "Item #4"]
    private var colors = [Color.red, Color.green, Color.blue, Color.purple]
    @State private var frames = Array<CGRect>(repeating: .zero, count: 4)
    
    var body: some View {
        VStack {
            ZStack {
                HStack(spacing: 4) {
                    ForEach(self.titles.indices, id: \.self) { index in
                        Button(action: { self.selectedIndex = index }) {
                            Text(self.titles[index])
                                .foregroundColor(.black)
                                .font(.system(size: 16, weight: .medium, design: .default))
                                .bold()
                        }.padding(EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)).background(
                            GeometryReader { geo in
                                Color.clear.onAppear { self.setFrame(index: index, frame: geo.frame(in: .global)) }
                            }
                        )
                    }
                }
                .background(
                    Capsule().fill(
                        self.colors[self.selectedIndex].opacity(0.4))
                        .frame(width: self.frames[self.selectedIndex].width,
                               height: self.frames[self.selectedIndex].height, alignment: .topLeading)
                        .offset(x: self.frames[self.selectedIndex].minX - self.frames[0].minX)
                    , alignment: .leading
                )
            }
            .animation(.default)
            .background(Capsule().stroke(Color.gray, lineWidth: 3))
        }
    }
    
    func setFrame(index: Int, frame: CGRect) {
        self.frames[index] = frame
    }
}

ContentView.swift:

struct ContentView: View {
    
    @State var itemsList = [Item]()
    
    func loadData() {
        if let url = Bundle.main.url(forResource: "Data", withExtension: "json") {
            do {
                let data = try Data(contentsOf: url)
                let decoder = JSONDecoder()
                let jsonData = try decoder.decode(Response.self, from: data)
                for post in jsonData.content {
                    self.itemsList.append(post)
                }
            } catch {
                print("error:\(error)")
            }
        }
    }
    
    var body: some View {
        NavigationView {
            VStack {
                Text("Item picker")
                    .font(.system(.title))
                    .bold()
                
                CustomPicker()
                
                Spacer()
                
                ScrollView {
                    VStack {
                        ForEach(itemsList) { item in
                            ItemView(text: item.text, username: item.username)
                                .padding(.leading)
                        }
                    }
                }
                .frame(height: UIScreen.screenHeight - 224)
            }
            .onAppear(perform: loadData)
        }
    }
}

Project file here

Teddy Bersentes
  • 166
  • 1
  • 10

1 Answers1

2

The problem with the code as-written is that the GeometryReader value is only sent on onAppear. That means that if any of the views around it change and the view is re-rendered (like when the data is loaded), those frames will be out-of-date.

I solved this by using a PreferenceKey instead, which will run on each render:

struct CustomPicker: View {
    @State var selectedIndex = 0
    var titles = ["Item #1", "Item #2", "Item #3", "Item #4"]
    private var colors = [Color.red, Color.green, Color.blue, Color.purple]
    @State private var frames = Array<CGRect>(repeating: .zero, count: 4)
    
    var body: some View {
        VStack {
            ZStack {
                HStack(spacing: 4) {
                    ForEach(self.titles.indices, id: \.self) { index in
                        Button(action: { self.selectedIndex = index }) {
                            Text(self.titles[index])
                                .foregroundColor(.black)
                                .font(.system(size: 16, weight: .medium, design: .default))
                                .bold()
                        }
                        .padding(EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16))
                        .measure() // <-- Here
                        .onPreferenceChange(FrameKey.self, perform: { value in
                            self.setFrame(index: index, frame: value) //<-- this will run each time the preference value changes, will will happen any time the frame is updated
                        })
                    }
                }
                .background(
                    Capsule().fill(
                        self.colors[self.selectedIndex].opacity(0.4))
                        .frame(width: self.frames[self.selectedIndex].width,
                               height: self.frames[self.selectedIndex].height, alignment: .topLeading)
                        .offset(x: self.frames[self.selectedIndex].minX - self.frames[0].minX)
                    , alignment: .leading
                )
            }
            .animation(.default)
            .background(Capsule().stroke(Color.gray, lineWidth: 3))
        }
    }
    
    func setFrame(index: Int, frame: CGRect) {
        print("Setting frame: \(index): \(frame)")
        self.frames[index] = frame
    }
}

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

extension View {
    func measure() -> some View {
        self.background(GeometryReader { geometry in
            Color.clear
                .preference(key: FrameKey.self, value: geometry.frame(in: .global))
        })
    }
}

Note that the original .background call was taken out and was replaced with .measure() and .onPreferenceChange -- look for where the //<-- Here note is.

Besides that and the PreferenceKey and View extension, nothing else is changed.

jnpdx
  • 45,847
  • 6
  • 64
  • 94