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

5
  • For custom segment control you can implement it using UIViewRepresentable protocol Commented Mar 22, 2020 at 20:46
  • @Mac3n that doesnt help much Commented Mar 22, 2020 at 21:34
  • Can you add an image showing how you want the picker to look? Commented Mar 25, 2020 at 12:57
  • @LuLuGaGa i updated the question with the image of the final product Commented Mar 25, 2020 at 14:28
  • @LuLuGaGa 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? Commented Apr 14, 2020 at 21:44

4 Answers 4

23
+50

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() } } 
Sign up to request clarification or add additional context in comments.

5 Comments

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.
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: stackoverflow.com/questions/57258846/…
To scroll the horizontal ScrollView: I actually just now stumbled over another SO post from the same user: stackoverflow.com/questions/60855852/…
Amazing answer. I would just replace the default animation with .easeInOut(duration: 0.2) to look even more similar to Picker animation
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: stackoverflow.com/a/66512870/560942
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() } } 

2 Comments

thank you for your time, but the above answer was dead on
The other answer is really nice, especially the way it reproduces the picker animation.
2

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

Comments

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) } } } } 

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.