SwiftUI Create a Custom Segmented Control also in a ScrollView
Asked Answered
M

4

7

Below is my code to create a standard segmented control.

struct ContentView: View {

    @State private var favoriteColor = 0
    var colors = ["Red", "Green", "Blue"]

    var body: some View {
        VStack {
            Picker(selection: $favoriteColor, label: Text("What is your favorite color?")) {
                ForEach(0..<colors.count) { index in
                    Text(self.colors[index]).tag(index)
                }
            }.pickerStyle(SegmentedPickerStyle())

            Text("Value: \(colors[favoriteColor])")
        }
    }
}

My question is how could I modify it to have a customized segmented control where I can have the boarder rounded along with my own colors, as it was somewhat easy to do with UIKit? Has any one done this yet.

I prefect example is the Uber eats app, when you select a restaurant you can scroll to the particular portion of the menu by selecting an option in the customized segmented control.

Included are the elements I'm looking to have customized:

enter image description here

* UPDATE *

Image of the final design

enter image description here

Mazur answered 22/3, 2020 at 20:18 Comment(5)
For custom segment control you can implement it using UIViewRepresentable protocolConformable
@Conformable that doesnt help muchMazur
Can you add an image showing how you want the picker to look?Elastin
@Elastin i updated the question with the image of the final productMazur
@Elastin can I ask one question, I put this in a horizontal scrollview , would you happen to know how I can adjust for the offset to that the selected segment wont be cutoff if user selects a selection half clipped?Mazur
S
23

Is this what you are looking for?

enter image description here

import SwiftUI

struct CustomSegmentedPickerView: View {
  @State private var selectedIndex = 0
  private var titles = ["Round Trip", "One Way", "Multi-City"]
  private var colors = [Color.red, Color.green, Color.blue]
  @State private var frames = Array<CGRect>(repeating: .zero, count: 3)

  var body: some View {
    VStack {
      ZStack {
        HStack(spacing: 10) {
          ForEach(self.titles.indices, id: \.self) { index in
            Button(action: { self.selectedIndex = index }) {
              Text(self.titles[index])
            }.padding(EdgeInsets(top: 16, leading: 20, bottom: 16, trailing: 20)).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))

      Picker(selection: self.$selectedIndex, label: Text("What is your favorite color?")) {
        ForEach(0..<self.titles.count) { index in
          Text(self.titles[index]).tag(index)
        }
      }.pickerStyle(SegmentedPickerStyle())

      Text("Value: \(self.titles[self.selectedIndex])")
      Spacer()
    }
  }

  func setFrame(index: Int, frame: CGRect) {
    self.frames[index] = frame
  }
}


struct CustomSegmentedPickerView_Previews: PreviewProvider {
  static var previews: some View {
    CustomSegmentedPickerView()
  }
}
Sandusky answered 25/3, 2020 at 17:50 Comment(5)
this is exactly what I was referring to. Question what if I needed it in horizontal scroll view as I had more options that the three i mentioned and I wanted to to span past the screen size.Mazur
The view --kinda-- still works when you wrap in a horizontal ScrollView. But it, of course, doesn't adjust the scroll position which makes it not fully useful. Maybe you can gather some ideas on how to do it from this post: #57259346Sandusky
To scroll the horizontal ScrollView: I actually just now stumbled over another SO post from the same user: #60856352Sandusky
Amazing answer. I would just replace the default animation with .easeInOut(duration: 0.2) to look even more similar to Picker animationStaffman
For those that come across this in the future, because the measurement for the views is only taken on onAppear, it'll stop functioning if the layout changes. Solution here: https://mcmap.net/q/1475052/-custom-segmented-controller-swiftui-frame-issuePetrosal
F
4

If I'm following the question aright the starting point might be something like the code below. The styling, clearly, needs a bit of attention. This has a hard-wired width for segments. To be more flexible you'd need to use a Geometry Reader to measure what was available and divide up the space.

enter image description here

struct ContentView: View {

      @State var selection = 0

      var body: some View {

            let item1 = SegmentItem(title: "Some Way", color: Color.blue, selectionIndex: 0)
            let item2 = SegmentItem(title: "Round Zip", color: Color.red, selectionIndex: 1)
            let item3 = SegmentItem(title: "Multi-City", color: Color.green, selectionIndex: 2)

            return VStack() {
                  Spacer()
                  Text("Selected Item: \(selection)")
                  SegmentControl(selection: $selection, items: [item1, item2, item3])
                  Spacer()
            }
      }
}


struct SegmentControl : View {

      @Binding var selection : Int
      var items : [SegmentItem]

      var body : some View {

            let width : CGFloat = 110.0

            return HStack(spacing: 5) {
                  ForEach (items, id: \.self) { item in
                        SegmentButton(text: item.title, width: width, color: item.color, selectionIndex: item.selectionIndex, selection: self.$selection)
                  }
            }.font(.body)
                  .padding(5)
                  .background(Color.gray)
                  .cornerRadius(10.0)
      }
}


struct SegmentButton : View {

      var text : String
      var width : CGFloat
      var color : Color
      var selectionIndex = 0
      @Binding var selection : Int

      var body : some View {
            let label = Text(text)
                  .padding(5)
                  .frame(width: width)
                  .background(color).opacity(selection == selectionIndex ? 1.0 : 0.5)
                  .cornerRadius(10.0)
                  .foregroundColor(Color.white)
                  .font(Font.body.weight(selection == selectionIndex ? .bold : .regular))

            return Button(action: { self.selection = self.selectionIndex }) { label }
      }
}


struct SegmentItem : Hashable {
      var title : String = ""
      var color : Color = Color.white
      var selectionIndex = 0
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Fazio answered 25/3, 2020 at 16:10 Comment(2)
thank you for your time, but the above answer was dead onMazur
The other answer is really nice, especially the way it reproduces the picker animation.Fazio
H
1

As no solution above could solve the feel of the native solution I created my own based on the above implementations. https://github.com/poromaa/swiftui-capsule-picker/tree/main

import SwiftUI

struct CapsulePicker: View {
    @Binding var selectedIndex: Int
    @State private var hoverIndex = 0
    @State private var dragOffset: CGFloat = 0
    @State private var optionWidth: CGFloat = 0
    @State private var totalSize: CGSize = .zero
    @State private var isDragging: Bool = false
    let titles: [String]
    
    var body: some View {
        ZStack(alignment: .leading) {
            Capsule()
                .fill(Color.accentColor)
                .padding(isDragging ? 2 : 0)
                .frame(width: optionWidth, height: totalSize.height)
                .offset(x: dragOffset)
                .gesture(
                    LongPressGesture(minimumDuration: 0.01)
                        .sequenced(before: DragGesture())
                        .onChanged { value in
                            switch value {
                            case .first(true):
                                isDragging = true
                            case .second(true, let drag):
                                let translationWidth = (drag?.translation.width ?? 0) + CGFloat(selectedIndex) * optionWidth
                                hoverIndex = Int(round(min(max(0, translationWidth), optionWidth * CGFloat(titles.count - 1)) / optionWidth))
                            default:
                                isDragging = false
                            }
                        }
                        .onEnded { value in
                            if case .second(true, let drag?) = value {
                                let predictedEndOffset = drag.translation.width + CGFloat(selectedIndex) * optionWidth
                                selectedIndex = Int(round(min(max(0, predictedEndOffset), optionWidth * CGFloat(titles.count - 1)) / optionWidth))
                                hoverIndex = selectedIndex
                            }
                            isDragging = false
                        }
                        .simultaneously(with: TapGesture().onEnded { _ in isDragging = false })
                )
            
                .animation(.spring(), value: dragOffset)
                .animation(.spring(), value: isDragging)
            
            Capsule().fill(Color.accentColor).opacity(0.2)
                .padding(-4)
            
            HStack(spacing: 0) {
                ForEach(titles.indices, id: \.self) { index in
                    Text(titles[index])
                        .frame(width: optionWidth, height: totalSize.height, alignment: .center)
                        .foregroundColor(hoverIndex == index ? .white : .black)
                        .animation(.easeInOut, value: hoverIndex)
                        .font(.system(size: 14, weight: .bold))
                    
                        .contentShape(Capsule())
                        .onTapGesture {
                            selectedIndex = index
                            hoverIndex = index
                        }.allowsHitTesting(selectedIndex != index)
                }
            }
            .onChange(of: hoverIndex) {i in
                dragOffset =  CGFloat(i) * optionWidth
            }
            .onChange(of: selectedIndex) {i in
                hoverIndex = i
            }
            .frame(width: totalSize.width, alignment: .leading)
        }
        .background(GeometryReader { proxy in Color.clear.onAppear { totalSize = proxy.size } })
        .onChange(of: totalSize) { _ in optionWidth = totalSize.width/CGFloat(titles.count) }
        .onAppear { hoverIndex = selectedIndex }
        .frame(height: 50)
        .padding([.leading, .trailing], 10)
    }
}

struct CapsulePickerPreview: View {
    @State private var selectedIndex = 0
    var titles = ["Red", "Greenas", "Blue"]
    var body: some View {
        VStack {
            CapsulePicker(selectedIndex: $selectedIndex, titles: titles)      .padding()
            Text("Selected index: \(selectedIndex)")
            
            VStack {
                Picker(selection: self.$selectedIndex, label: Text("What is your favorite color?")) {
                    ForEach(titles.indices, id: \.self) { index in
                        Text(self.titles[index]).tag(index)
                    }
                }.pickerStyle(SegmentedPickerStyle())
                
                
                
                Text("Value: \(self.titles[self.selectedIndex])")
                Spacer()
            }
        }
        .padding()
    }
}

struct CapsulePicker_Previews: PreviewProvider {
    static var previews: some View {
        CapsulePickerPreview()
    }
}

enter image description here

Horst answered 18/6, 2023 at 7:50 Comment(0)
T
0

None of the above solutions worked for me as the GeometryReader returns different values once placed in a Navigation View that throws off the positioning of the active indicator in the background. I found alternate solutions, but they only worked with fixed length menu strings. Perhaps there is a simple modification to make the above code contributions work, and if so, I would be eager to read it. If you're having the same issues I was, then this may work for you instead.

Thanks to inspiration from a Reddit user "End3r117" and this SwiftWithMajid article, https://swiftwithmajid.com/2020/01/15/the-magic-of-view-preferences-in-swiftui/, I was able to craft a solution. This works either inside or outside of a NavigationView and accepts menu items of various lengths.

struct SegmentMenuPicker: View {
    var titles: [String]
    var color: Color
    
    @State private var selectedIndex = 0
    @State private var frames = Array<CGRect>(repeating: .zero, count: 5)

    var body: some View {
        VStack {
            ZStack {
                HStack(spacing: 10) {
                    ForEach(self.titles.indices, id: \.self) { index in
                        Button(action: {
                            print("button\(index) pressed")
                            self.selectedIndex = index
                        }) {
                            Text(self.titles[index])
                                .foregroundColor(color)
                                .font(.footnote)
                                .fontWeight(.semibold)
                        }
                        .padding(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5))
                        .modifier(FrameModifier())
                        .onPreferenceChange(FramePreferenceKey.self) { self.frames[index] = $0 }
                    }
                }
                .background(
                    Rectangle()
                        .fill(self.color.opacity(0.4))
                        .frame(
                            width: self.frames[self.selectedIndex].width,
                            height: 2,
                            alignment: .topLeading)
                        .offset(x: self.frames[self.selectedIndex].minX - self.frames[0].minX, y: self.frames[self.selectedIndex].height)
                    , alignment: .leading
                )
            }
            .padding(.bottom, 15)
            .animation(.easeIn(duration: 0.2))

            Text("Value: \(self.titles[self.selectedIndex])")
            Spacer()
        }
    }
}

struct FramePreferenceKey: PreferenceKey {
    static var defaultValue: CGRect = .zero

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

struct FrameModifier: ViewModifier {
    private var sizeView: some View {
        GeometryReader { geometry in
            Color.clear.preference(key: FramePreferenceKey.self, value: geometry.frame(in: .global))
        }
    }

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

struct NewPicker_Previews: PreviewProvider {
    static var previews: some View {
        VStack {
            SegmentMenuPicker(titles: ["SuperLongValue", "1", "2", "Medium", "AnotherSuper"], color: Color.blue)
            NavigationView {
                SegmentMenuPicker(titles: ["SuperLongValue", "1", "2", "Medium", "AnotherSuper"], color: Color.red)
            }
        }
    }
}
Twocolor answered 13/11, 2020 at 6:13 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.