SwiftUI Create a Custom Segmented Control also in a ScrollView - ios

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:
* UPDATE *
Image of the final design

Is this what you are looking for?
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()
}
}

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

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

Related

SwiftUI - Segment control using buttons not deselecting other buttons properly

I am trying to make a custom segment control using a button array.
When a button is tapped, the correct value is passed to the main view and can be pressed multiple times without issue. The problem is that the previous button which was selected stays selected. The code below should work to recreate the issue. Thanks for the help.
Segment view
struct WaveTypeGridView: View {
var waveTypes = ["Beach Break" ,"Reef Break", "Point Break", "Rivermouth"]
#Binding var waveTypeSelected: String
#State var typeSelected: String
let columnSpacing: CGFloat = 5
let rowSpacing: CGFloat = 10
var gridLayout: [GridItem] {
return Array(repeating: GridItem(.flexible(), spacing: rowSpacing), count: 1)
}
var body: some View {
VStack {
Text("Wave Type \(typeSelected)")
ScrollView(.horizontal, showsIndicators: false, content: {
LazyHGrid(rows: gridLayout, alignment: .center, spacing: columnSpacing, pinnedViews: [], content: {
ForEach(waveTypes, id: \.self) { type in
if type == typeSelected {
TypeItemView(selected: true, typeSelected: self.$typeSelected, name: type)
} else {
TypeItemView(selected: false, typeSelected: self.$typeSelected, name: type)
}
}
})//: GRID
.frame(height: 18)
.padding(.vertical, 8)
.padding(.horizontal, 10)
})//: SCROLL
.background(Color(.systemGray5).cornerRadius(8))
}
}
}
struct WaveTypeGridView_Previews: PreviewProvider {
static var previews: some View {
WaveTypeGridView(waveTypeSelected: .constant(surfDataTests[0].waveType), typeSelected: "Beach Break")
.previewLayout(.sizeThatFits)
}
}
Button View
struct TypeItemView: View {
#State var selected: Bool
#Binding var typeSelected: String
let name: String
func test() {
}
var body: some View {
Button(action: {
selected.toggle()
typeSelected = name
}, label: {
HStack(alignment: .center, spacing: 6, content: {
Text(name)
.font(.footnote)
.fontWeight(.medium)
.foregroundColor(selected ? Color.white : Color.black)
})//: HSTACK
.frame(height: 18)
.padding(6)
.background(selected ? Color.blue
.cornerRadius(8)
: Color.white
.cornerRadius(8))
.background (
RoundedRectangle(cornerRadius: 8)
)
})//: BUTTON
}
}
struct TypeItemView_Previews: PreviewProvider {
static var previews: some View {
TypeItemView(selected: false, typeSelected: .constant("Rivermouth"), name:"Rivermouth")
.previewLayout(.sizeThatFits)
.padding()
.background(Color.gray)
}
}
You seem to have made your state tracking more complex than it needs to be. All you need to know is the currently selected wave type. You don't need to track a separate selected state, since the button can determine this from it's own value and the currently selected value.
For the grid view you can just have a single #State property for the selected wave type (You could inject this as an #Binding if required).
Pass the selected break type and the view's target value to the TypeItemView
struct WaveTypeGridView: View {
var waveTypes = ["Beach Break" ,"Reef Break", "Point Break", "Rivermouth"]
#State var typeSelected: String = "Beach Break"
let columnSpacing: CGFloat = 5
let rowSpacing: CGFloat = 10
var gridLayout: [GridItem] {
return Array(repeating: GridItem(.flexible(), spacing: rowSpacing), count: 1)
}
var body: some View {
VStack {
Text("Wave Type \(typeSelected)")
ScrollView(.horizontal, showsIndicators: false, content: {
LazyHGrid(rows: gridLayout, alignment: .center, spacing: columnSpacing, pinnedViews: [], content: {
ForEach(waveTypes, id: \.self) { type in
TypeItemView(typeSelected: self.$typeSelected, name: type)
}
})//: GRID
.frame(height: 18)
.padding(.vertical, 8)
.padding(.horizontal, 10)
})//: SCROLL
.background(Color(.systemGray5).cornerRadius(8))
}
}
}
Then in your TypeItemView you can create a computed property for selected based on the current value and this view's target value.
struct TypeItemView: View {
#Binding var typeSelected: String
let name: String
private var selected: Bool {
return typeSelected == name
}
func test() {
}
var body: some View {
Button(action: {
typeSelected = name
}, label: {
HStack(alignment: .center, spacing: 6, content: {
Text(name)
.font(.footnote)
.fontWeight(.medium)
.foregroundColor(self.selected ? Color.white : Color.black)
})//: HSTACK
.frame(height: 18)
.padding(6)
.background(self.selected ? Color.blue
.cornerRadius(8)
: Color.white
.cornerRadius(8))
.background (
RoundedRectangle(cornerRadius: 8)
)
})//: BUTTON
}
}
Now the whole control only depends on one #State item.

Using a List instead of a ScrollView and ForEach

I have the following code to show some Cards which expand to fullscreen if the user taps on one of them:
import SwiftUI
struct ContentView: View {
#State private var cards = [
Card(title: "testA", subtitle: "subtitleA"),
Card(title: "testB", subtitle: "subtitleB"),
Card(title: "testC", subtitle: "subtitleC"),
Card(title: "testD", subtitle: "subtitleD"),
Card(title: "testE", subtitle: "subtitleE")
]
#State private var showDetails: Bool = false
#State private var heights = [Int: CGFloat]()
var body: some View {
ScrollView {
VStack {
if(!cards.isEmpty) {
ForEach(self.cards.indices, id: \.self) { index in
GeometryReader { reader in
CardView(card: self.$cards[index], isDetailed: self.$showDetails)
.offset(y: self.cards[index].showDetails ? -reader.frame(in: .global).minY : 0)
.fixedSize(horizontal: false, vertical: !self.cards[index].showDetails)
.background(GeometryReader {
Color.clear
.preference(key: ViewHeightKey.self, value: $0.frame(in: .local).size.height)
})
.onTapGesture {
withAnimation(.spring()) {
self.cards[index].showDetails.toggle()
self.showDetails.toggle()
}
}
}
.frame(height: self.cards[index].showDetails ? UIScreen.main.bounds.height : self.heights[index], alignment: .center)
.onPreferenceChange(ViewHeightKey.self) { value in
self.heights[index] = value
}
}
} else {
ActivityIndicator(style: UIActivityIndicatorView.Style.medium).frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height / 2)
}
}
}
.onAppear() {
// load data
}
}
}
struct CardView : View {
#Binding var card : Card
#Binding var isDetailed : Bool
var body: some View {
VStack(alignment: .leading){
ScrollView(showsIndicators: isDetailed && card.showDetails) {
HStack (alignment: .center){
VStack (alignment: .leading){
HStack(alignment: .top){
Text(card.subtitle).foregroundColor(Color.gray)
Spacer()
}
Text(card.title).fontWeight(Font.Weight.bold).fixedSize(horizontal: false, vertical: true)
}
}
.padding([.top, .horizontal]).padding(isDetailed && card.showDetails ? [.top] : [] , 34)
Image("placeholder-image").resizable().scaledToFit().frame(width: UIScreen.main.bounds.width - 60).padding(.bottom)
if isDetailed && card.showDetails {
Text("Lorem ipsum ... ")
}
}
}
.background(Color.green)
.cornerRadius(16)
.shadow(radius: 12)
.padding(isDetailed && card.showDetails ? [] : [.top, .horizontal])
.opacity(isDetailed && card.showDetails ? 1 : (!isDetailed ? 1 : 0))
.edgesIgnoringSafeArea(.all)
}
}
struct Card : Identifiable {
public var id = UUID()
public var title: String
public var subtitle : String
public var showDetails : Bool = false
}
struct ViewHeightKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
Now i want to change both (or at least one) Scrollviews in ContentView and CardView to Lists, because of the lazy loading for better performance. But changing the ScrollView in ContentView results in glitched animations. And if i change it to a List in the CardView, no card is even showing up anymore.
Any idea how I can change the code to use Lists?
So as noted in the comments, the flickering is a bug on Apple's end. The problem is you want to first resize then change offset because if you change offset and then resize you will get the flickering issue when cells overlap. I wrote a simple test that can be found in this pastebin
Because of my time constraint I was able to solve the weird scrolling behavior where the 2nd/3rd/etc... card would jump to the top by changing the animations order. I will come back to this post hopefully tonight and fix the flickering by fixing the order in which animations happens first. They have to be chained.
Here is my code where I
init() {
UITableView.appearance().separatorStyle = .none
UITableView.appearance().backgroundColor = .clear
UITableView.appearance().layoutMargins = .zero
UITableViewCell.appearance().backgroundColor = .clear
UITableViewCell.appearance().layoutMargins = .zero
}
...
List {
VStack {
if(!cards.isEmpty) {
ForEach(self.cards.indices, id: \.self) { index in
GeometryReader { reader in
CardView(card: self.$cards[index], isDetailed: self.$showDetails)
// 1. Note I switched the order of offset and fixedsize (not that it will make difference in this step but its good practice
// 2. I added animation in between
.fixedSize(horizontal: false, vertical: !self.cards[index].showDetails)
.animation(Animation.spring())
.offset(y: self.cards[index].showDetails ? -reader.frame(in: .global).minY : 0)
.animation(Animation.spring())
.background(GeometryReader {
Color.clear
.preference(key: ViewHeightKey.self, value: $0.frame(in: .local).size.height)
})
.onTapGesture {
// Note I commented this out, it's the source of the bug.
// withAnimation(Animation.spring().delay()) {
self.cards[index].showDetails.toggle()
self.showDetails.toggle()
// }
}
.zIndex(self.cards[index].showDetails ? 100 : -1)
}
.frame(height: self.cards[index].showDetails ? UIScreen.main.bounds.height : self.heights[index], alignment: .center)
.onPreferenceChange(ViewHeightKey.self) { value in
self.heights[index] = value
}
}
} else {
Text("Loading")
}
}
// I added this for styling
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
}
// I added this for styling
.listStyle(PlainListStyle())
.onAppear() {
// load data
}
NOTE: If however, you wait a second or two between opening and closing, you wouldn't notice that flickering behavior.
NOTE2: This chaining behavior can also be observed in the App Store But they blend the animations perfectly. If you pay attention you will find some delay
EDIT 1
In this edit I would like to point out that the previous solutions does enhance the animations, it does still have the flickering issue, unfortunately, not much can be done to that except we can have a delay between presenting and hiding. Even Apple has done so. If you pay attention to the App Store on iOS you will notice that when you try to open one of their cards you wont be able to press "X" right away, you would first notice that it is unresponsive and that is because even Apple had to add some delay between presenting and being able to hide. The delay is basically a work around. Furthermore, you can also notice that when you tap the card, it won't be presented right away. It would actually take some time to execute animations which is again just a trick to add delay.
So to solve the flickering issue you can add a simple timer that counts down before allowing the user to be able to close the opened card or open the closed card again. This delay can be the duration of some of your animations so the user won't feel any lag in your app.
here I wrote a simple code to demonstrate how can that be done. Notice though, the blending of animations or delays is no where perfect and can be tweaked even further. I only wanted to demonstrate that it is possible to get rid of the flickering or at least reduce it by a lot. There might be far better solutions than this, but that's what I came up with :)
EDIT 2: On a second test I was able to point out that this flickering is actually happening because your list doesn't have animations. So a quick solution can be done by applying animations to the List; Keep in mind the above solution in Edit 1: would works just as good but it would require a lot of trials and errors, without further waiting, here is my solution.
import SwiftUI
struct StackOverflow22: View {
#State private var cards = [
Card(title: "testA", subtitle: "subtitleA"),
Card(title: "testB", subtitle: "subtitleB"),
Card(title: "testC", subtitle: "subtitleC"),
Card(title: "testD", subtitle: "subtitleD"),
Card(title: "testE", subtitle: "subtitleE")
]
#State private var showDetails: Bool = false
#State private var heights = [Int: CGFloat]()
init() {
UITableView.appearance().separatorStyle = .none
UITableView.appearance().backgroundColor = .clear
UITableView.appearance().layoutMargins = .zero
UITableViewCell.appearance().backgroundColor = .clear
UITableViewCell.appearance().layoutMargins = .zero
}
var body: some View {
List {
VStack {
if(!cards.isEmpty) {
ForEach(self.cards.indices, id: \.self) { index in
GeometryReader { reader in
CardView(card: self.$cards[index], isDetailed: self.$showDetails)
.fixedSize(horizontal: false, vertical: !self.cards[index].showDetails)
.offset(y: self.cards[index].showDetails ? -reader.frame(in: .global).minY : 0)
.background(GeometryReader {
Color.clear
.preference(key: ViewHeightKey.self, value: $0.frame(in: .local).size.height)
})
.onTapGesture {
withAnimation(.spring()) {
self.cards[index].showDetails.toggle()
self.showDetails.toggle()
}
}
}
.frame(height: self.cards[index].showDetails ? UIScreen.main.bounds.height : self.heights[index], alignment: .center)
.onPreferenceChange(ViewHeightKey.self) { value in
self.heights[index] = value
}
}
} else {
Text("Loading")
}
}
.animation(Animation.linear)
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
}
.listStyle(PlainListStyle())
.onAppear() {
// load data
}
}
}
struct CardView : View {
#Binding var card : Card
#Binding var isDetailed : Bool
var body: some View {
VStack(alignment: .leading){
ScrollView(showsIndicators: isDetailed && card.showDetails) {
HStack (alignment: .center){
VStack (alignment: .leading){
HStack(alignment: .top){
Text(card.subtitle).foregroundColor(Color.gray)
Spacer()
}
Text(card.title).fontWeight(Font.Weight.bold).fixedSize(horizontal: false, vertical: true)
}
}
.padding([.top, .horizontal]).padding(isDetailed && card.showDetails ? [.top] : [] , 34)
Image("placeholder-image").resizable().scaledToFit().frame(width: UIScreen.main.bounds.width - 60).padding(.bottom)
if isDetailed && card.showDetails {
Text("Lorem ipsum ... ")
}
}
}
.background(Color.green)
.cornerRadius(16)
.shadow(radius: 12)
.padding(isDetailed && card.showDetails ? [] : [.top, .horizontal])
.opacity(isDetailed && card.showDetails ? 1 : (!isDetailed ? 1 : 0))
.edgesIgnoringSafeArea(.all)
}
}
struct Card : Identifiable, Hashable {
public var id = UUID()
public var title: String
public var subtitle : String
public var showDetails : Bool = false
}
struct StackOverflow22_Previews: PreviewProvider {
static var previews: some View {
StackOverflow22()
}
}
struct ViewHeightKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}

SwiftUI: Transition won't slide

I have control with an edit and a list in a Stack. I want the list to drop down from top to bottom to animate like a fancy menu. However I am having a few issues. If I just try to use the move transition nothing happens. If I use the scale transition it always scales form center never from top down. This was just trying to get the transition overridden to slide. Anything but fade.
My control looks like so
struct Search: Identifiable {
let id: UUID
let text: String
}
struct SearchBox: View {
#State var searchParam: String = ""
#State var stuff = [Search]()
init() {
// To remove only extra separators below the list:
UITableView.appearance().tableFooterView = UIView()
// To remove all separators including the actual ones:
UITableView.appearance().separatorStyle = .none
}
var body: some View {
var binding = Binding<String>(
get: {
self.searchParam
},
set: {
self.stuff.append(
Search(id: UUID(), text: $0))
self.searchParam = $0
})
return VStack(spacing: 0.0) {
TextField("Search", text: binding )
.font(.title)
.padding()
.background(Color.white)
Color(.darkGray)
.frame(height: 1.0)
if stuff.count > 0 {
List(stuff, id: \.id) {
Text($0.text)
}
.transition(.slide)
}
}
}
struct SearchBox_Preview: PreviewProvider {
static var previews: some View{
SearchBox()
}
}
}
The content view is simple..
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .topLeading) {
Color.blue
SearchBox()
.frame(width: geometry.size.width * 0.40, alignment: .topLeading)
.frame(minHeight: 0, maxHeight: geometry.size.height * 0.40,
alignment: .topLeading)
.padding()
.clipped()
.shadow(radius: 5.0)
}
.background(Color.clear)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I have to be missing, or not getting, something simple
Answered my own question. We need to force an animation by using withAnimation on a state. So I have changed my biding to be like so:
struct Search: Identifiable {
let id: UUID
let text: String
}
struct SearchBox: View {
#State var searchParam: String = ""
#State var stuff = [Search]()
#State var showList = false
init() {
// To remove only extra separators below the list:
UITableView.appearance().tableFooterView = UIView()
// To remove all separators including the actual ones:
UITableView.appearance().separatorStyle = .none
}
var body: some View {
var binding = Binding<String>(
get: {
self.searchParam
},
set: {
self.stuff.append(
Search(id: UUID(), text: $0)
)
self.searchParam = $0
// change visibility state with in animation block.
withAnimation { self.showList = stuff.count > 0 }
})
return VStack(spacing: 0.0) {
TextField("Search", text: binding )
.font(.title)
.padding()
.background(Color.white)
Color(.darkGray)
.frame(height: 1.0)
if showList {
List(stuff, id: \.id) {
Text($0.text)
}
.transition(.slide)
}
}
}
struct SearchBox_Preview: PreviewProvider {
static var previews: some View{
SearchBox()
}
}
}

SwiftUI: How to implement Radio button in SwiftUI

I'm creating a simple form app. In that, I have checkboxes and Radio buttons, but I don't know how to do it.
I have done below code to dynamically change the selected option's colour. But it can be select multiple values. I need to select only one value among 5 values like a radio button.
E.g:
I'm taping on the second radio button. Now if I select the fourth radio button, the second one should deselect and the fourth one should get selected.
struct DCTableCell: View {
#Binding var dcValue: String
#State var isSelected: Bool = false
var body: some View {
Button(action: {
print("Tapped")
self.isSelected.toggle()
}){
ZStack {
RoundedRectangle(cornerRadius: 8)
.stroke(self.isSelected ? Color.init("borderSelected"): Color.init("border"))
.frame(height: 56)
.foregroundColor(.clear)
HStack {
Text(dcValue)
.font(.custom("Montserrat", size: 16))
.fontWeight(.medium)
.foregroundColor(self.isSelected ? Color.init("borderSelected") : .white)
.padding()
Spacer()
ZStack {
Circle()
.stroke(self.isSelected ? Color.init("borderSelected") : Color("circleBorder"))
.frame(width: 18, height: 18)
.padding()
Circle()
.frame(width: 10, height: 10)
.foregroundColor(self.isSelected ? Color.init("borderSelected"): Color.clear)
}
}
}
}
}
}
check this out...an easy to use SwiftUI RadiobuttonGroup for iOS
you can use it like this:
RadioButtonGroup(items: ["Rome", "London", "Paris", "Berlin", "New York"], selectedId: "London") { selected in
print("Selected is: \(selected)")
}
and here is the code:
struct ColorInvert: ViewModifier {
#Environment(\.colorScheme) var colorScheme
func body(content: Content) -> some View {
Group {
if colorScheme == .dark {
content.colorInvert()
} else {
content
}
}
}
}
struct RadioButton: View {
#Environment(\.colorScheme) var colorScheme
let id: String
let callback: (String)->()
let selectedID : String
let size: CGFloat
let color: Color
let textSize: CGFloat
init(
_ id: String,
callback: #escaping (String)->(),
selectedID: String,
size: CGFloat = 20,
color: Color = Color.primary,
textSize: CGFloat = 14
) {
self.id = id
self.size = size
self.color = color
self.textSize = textSize
self.selectedID = selectedID
self.callback = callback
}
var body: some View {
Button(action:{
self.callback(self.id)
}) {
HStack(alignment: .center, spacing: 10) {
Image(systemName: self.selectedID == self.id ? "largecircle.fill.circle" : "circle")
.renderingMode(.original)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: self.size, height: self.size)
.modifier(ColorInvert())
Text(id)
.font(Font.system(size: textSize))
Spacer()
}.foregroundColor(self.color)
}
.foregroundColor(self.color)
}
}
struct RadioButtonGroup: View {
let items : [String]
#State var selectedId: String = ""
let callback: (String) -> ()
var body: some View {
VStack {
ForEach(0..<items.count) { index in
RadioButton(self.items[index], callback: self.radioGroupCallback, selectedID: self.selectedId)
}
}
}
func radioGroupCallback(id: String) {
selectedId = id
callback(id)
}
}
struct ContentView: View {
var body: some View {
HStack {
Text("Example")
.font(Font.headline)
.padding()
RadioButtonGroup(items: ["Rome", "London", "Paris", "Berlin", "New York"], selectedId: "London") { selected in
print("Selected is: \(selected)")
}
}.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct ContentViewDark_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environment(\.colorScheme, .dark)
.darkModeFix()
}
}
Ok this is not an ideal solution but it works and hopefully opens your eyes to improve what you have. I give every RadioButton an ID and when the selected ID changes it updates:
struct DCTableCell: View {
var id: Int
#Binding var dcValue: String
#Binding var selectedID: Int
var body: some View {
Button(action: {
print("Tapped")
self.selectedID = self.id
}){
ZStack {
RoundedRectangle(cornerRadius: 8)
.stroke(self.id == self.selectedID ? Color.blue : Color.white)
.frame(height: 56)
.foregroundColor(.clear)
HStack {
Text(dcValue)
.font(.custom("Montserrat", size: 16))
.fontWeight(.medium)
.foregroundColor(self.id == self.selectedID ? .blue : .white)
.padding()
Spacer()
ZStack {
Circle()
.stroke(self.id == self.selectedID ? Color.blue : .black)
.frame(width: 18, height: 18)
.padding()
Circle()
.frame(width: 10, height: 10)
.foregroundColor(self.id == self.selectedID ? Color.blue: Color.clear)
}
}
}
}
}
}
And here how to use it. maybe you should create an array with ids and the strings that you want to pass in.
struct ContentView: View {
#State var str = "lolz"
#State var selectedID = -1
var body: some View {
VStack {
ForEach((1...5), id: \.self) { index in
DCTableCell(id: index, dcValue: self.$str, selectedID: self.$selectedID)
}
}
}
}
I hope this helps!
I have a similar solution, use the labels as the hashable tag which makes it very straight. So you only need to setting the outer layer: CustomDCPicker, like a general picker.
UIHostingController(rootView: CustomDCPicker())
struct CustomDCPicker: View {
#State var dcValue: String = ""
var body: some View {
VStack{
Text(dcValue).bold()
DCTable.init(dcValue: $dcValue, Labels: ["sample1","sample2","sample3","sample4","sample5"])
}
}
}
struct DCTable: View {
#Binding var dcValue: String
var Labels: [String] = []
var body: some View {
ForEach(Labels, id:\.self){
DCTableCell(dcValue: self.$dcValue, myLabel: $0)
}
}
}
struct DCTableCell: View {
#Binding var dcValue: String
var isSelected: Bool {
get{ self.dcValue == self.myLabel}
}
var myLabel : String
var body: some View {
Button(action: {
print("Tapped")
self.dcValue = self.myLabel
}){
ZStack {
RoundedRectangle(cornerRadius: 8.0)
.stroke(self.isSelected ? Color.red: Color.yellow)
.frame(height: 56)
.foregroundColor(.clear)
HStack {
Text(myLabel)
//.font(.custom("Montserrat", size: 16))
.fontWeight(.medium)
.foregroundColor(self.isSelected ? Color.red : .black)
.padding()
Spacer()
ZStack {
Circle()
.stroke(self.isSelected ? Color.red : Color.black)
.frame(width: 18, height: 18)
.padding()
Circle()
.frame(width: 10, height: 10)
.foregroundColor(self.isSelected ? Color.red: Color.clear)
}
}
}
}
}
}

How can I create a text with Checkbox in SwiftUI?

I have a requirement of Checkbox (✅ as in to-do list) with textfield. Currently I have created button object like below :
Button(action: {
// do when checked / unchecked
//...
}) {
HStack(alignment: .top, spacing: 10) {
Rectangle()
.fill(Color.white)
.frame(width:20, height:20, alignment: .center)
.cornerRadius(5)
Text("Todo item 1")
}
}
I need to preserve checked and unchecked state in SwiftUI.
Here is a simple, re-usable checkmark component I created that follows a color scheme similar to other checkmarks on iOS (e.g. selecting messages in the Messages app):
import SwiftUI
struct CheckBoxView: View {
#Binding var checked: Bool
var body: some View {
Image(systemName: checked ? "checkmark.square.fill" : "square")
.foregroundColor(checked ? Color(UIColor.systemBlue) : Color.secondary)
.onTapGesture {
self.checked.toggle()
}
}
}
struct CheckBoxView_Previews: PreviewProvider {
struct CheckBoxViewHolder: View {
#State var checked = false
var body: some View {
CheckBoxView(checked: $checked)
}
}
static var previews: some View {
CheckBoxViewHolder()
}
}
You can use it in other views like this:
...
#State private var checked = true
...
HStack {
CheckBoxView(checked: $checked)
Spacer()
Text("Element that requires checkmark!")
}
...
The best way for iOS devices is to create a CheckboxStyle struct and conform to the ToggleStyle protocol. That allows you to then use the built-in Toggle component provided by Apple.
// CheckboxStyle.swift
import SwiftUI
struct CheckboxStyle: ToggleStyle {
func makeBody(configuration: Self.Configuration) -> some View {
return HStack {
Image(systemName: configuration.isOn ? "checkmark.square" : "square")
.resizable()
.frame(width: 24, height: 24)
.foregroundColor(configuration.isOn ? .blue : .gray)
.font(.system(size: 20, weight: .regular, design: .default))
configuration.label
}
.onTapGesture { configuration.isOn.toggle() }
}
}
// Example usage in a SwiftUI view
Toggle(isOn: $checked) {
Text("The label")
}
.toggleStyle(CheckboxStyle())
On macOS, Apple already has created a CheckboxToggleStyle() that you can use for macOS 10.15+. But it isn't available for iOS - yet.
Toggle seems to work for both macOS and iOS, using the native control on each.
https://developer.apple.com/documentation/swiftui/toggle
A control that toggles between on and off states.
#State var isOn: Bool = true
var body: some View {
Toggle("My Checkbox Title", isOn: $isOn)
.padding()
}
macOS:
iOS:
We can take help of the #State from Apple, which persists value of a given type, through which a view reads and monitors the value.
Working example :
struct CheckboxFieldView: View {
#State var checkState: Bool = false
var body: some View {
Button(action:
{
//1. Save state
self.checkState = !self.checkState
print("State : \(self.checkState)")
}) {
HStack(alignment: .top, spacing: 10) {
//2. Will update according to state
Rectangle()
.fill(self.checkState ? Color.green : Color.red)
.frame(width:20, height:20, alignment: .center)
.cornerRadius(5)
Text("Todo item ")
}
}
.foregroundColor(Color.white)
}
}
Now, you can add CheckboxFieldView()
You'll want something like this:
struct TodoCell: View {
var todoCellViewModel: TodoCellViewModel
var updateTodo: ((_ id: Int) -> Void)
var body: some View {
HStack {
Image(systemName: (self.todoCellViewModel.isCompleted() ? "checkmark.square" : "square")).tapAction {
self.updateTodo(self.todoCellViewModel.getId())
}
Text(self.todoCellViewModel.getTitle())
}
.padding()
}
}
Your list could look something like this:
struct TodoList: View {
var todos: Todos
var updateTodo: ((_ id: Int) -> Void)
var body: some View {
List(self.todos) { todo in
TodoCell(todoCellViewModel: TodoCellViewModel(todo: todo), updateTodo: { (id) in
self.updateTodo(id)
})
}
}
}
Your model might look something like this:
public class TodoCellViewModel {
private var todo: Todo
public init(todo: Todo) {
self.todo = todo
}
public func isCompleted() -> Bool {
return self.todo.completed
}
public func getTitle() -> String {
return self.todo.title
}
public func getId() -> Int {
return self.todo.id
}
}
And finally a Todo class:
public class Todo: Codable, Identifiable {
public let id: Int
public var title: String
public var completed: Bool
}
None of this has actually been tested and not all of the code has been implemented but this should get you on the right track.
Here’s my take on it. I’m actually doing this for MacOS, but the process should be the same.
First, I had to fake the checkbox by creating two png images: and , calling them checkbox-on.png and checkbox-off.png respectively. These I put into Assets.xcassets.
I believe that for iOS, the images are already available.
Second, the view includes a state variable:
#State var checked = false
The rest is to implement a Button with an action, an image, some text, and some modifiers:
Button(action: {
checked.toggle()
}) {
Image(checked ? "checkbox-on" : "checkbox-off")
.renderingMode(.original)
.resizable()
.padding(0)
.frame(width: 14.0, height: 14.0)
.background(Color(NSColor.controlBackgroundColor))
Text("Choose me … !").padding(0)
}
.buttonStyle(PlainButtonStyle())
.background(Color(red: 0, green: 0, blue: 0, opacity: 0.02))
.cornerRadius(0)
checked is the boolean variable you want to toggle
The image depends on the value of the boolean, using the condition operator to choose between the two
renderingMode() ensures that the image appears correctly and resizable() is used to enable frame().
The rest of the modifiers are there to tweak the appearance.
Obviously, if you are going to make a habit of this, you can create a struct:
struct Checkbox: View {
#Binding var toggle: Bool
var text: String
var body: some View {
Button(action: {
self.toggle.toggle()
}) {
Image(self.toggle ? "checkbox-on" : "checkbox-off")
.renderingMode(.original)
.resizable()
.padding(0)
.frame(width: 14.0, height: 14.0)
.background(Color(NSColor.controlBackgroundColor))
Text(text).padding(0)
}
.buttonStyle(PlainButtonStyle())
.background(Color(red: 0, green: 0, blue: 0, opacity: 0.02))
.cornerRadius(0)
}
}
and then use:
Checkbox(toggle: self.$checked, text: "Choose me … !")
(Note that you need to use self.$checked on this one).
Finally, if you prefer to use a common alternative appearance, that of a filled in square for the check box, you can replace Image with:
Rectangle()
.fill(self.autoSave ? Color(NSColor.controlAccentColor) : Color(NSColor.controlColor))
.padding(4)
.border(Color(NSColor.controlAccentColor), width: 2)
.frame(width: 14, height: 14)
I learned a lot doing this, and hopefully, this will help.
Here is my way:
import SwiftUI
extension ToggleStyle where Self == CheckBoxToggleStyle {
static var checkbox: CheckBoxToggleStyle {
return CheckBoxToggleStyle()
}
}
// Custom Toggle Style
struct CheckBoxToggleStyle: ToggleStyle {
func makeBody(configuration: Configuration) -> some View {
Button {
configuration.isOn.toggle()
} label: {
Label {
configuration.label
} icon: {
Image(systemName: configuration.isOn ? "checkmark.square.fill" : "square")
.foregroundColor(configuration.isOn ? .accentColor : .secondary)
.accessibility(label: Text(configuration.isOn ? "Checked" : "Unchecked"))
.imageScale(.large)
}
}
.buttonStyle(PlainButtonStyle())
}
}
struct ContentView: View {
#State var isOn = false
var body: some View {
Toggle("Checkmark", isOn: $isOn).toggleStyle(.checkbox)
}
}
Unchecked:
Checked:
I found this solution here to be much better than using a completely custom made View:
https://swiftwithmajid.com/2020/03/04/customizing-toggle-in-swiftui/
He uses the ToggleStyle protocol to simply change the look of the toggle, instead of rebuilding it:
struct CheckboxToggleStyle: ToggleStyle {
func makeBody(configuration: Configuration) -> some View {
return HStack {
configuration.label
Spacer()
Image(systemName: configuration.isOn ? "checkmark.square" : "square")
.resizable()
.frame(width: 22, height: 22)
.onTapGesture { configuration.isOn.toggle() }
}
}
}
You can use the following code and change the color etc. This is an individual component and I used a callback method to get informed when the checkbox is selected or not.
Step 1: Create a customizable and reusable checkbox view
Step 2: Let use the component in the main view
Use the checkboxSelected() callback function to know which checkbox is selected or not.
import SwiftUI
//MARK:- Checkbox Field
struct CheckboxField: View {
let id: String
let label: String
let size: CGFloat
let color: Color
let textSize: Int
let callback: (String, Bool)->()
init(
id: String,
label:String,
size: CGFloat = 10,
color: Color = Color.black,
textSize: Int = 14,
callback: #escaping (String, Bool)->()
) {
self.id = id
self.label = label
self.size = size
self.color = color
self.textSize = textSize
self.callback = callback
}
#State var isMarked:Bool = false
var body: some View {
Button(action:{
self.isMarked.toggle()
self.callback(self.id, self.isMarked)
}) {
HStack(alignment: .center, spacing: 10) {
Image(systemName: self.isMarked ? "checkmark.square" : "square")
.renderingMode(.original)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: self.size, height: self.size)
Text(label)
.font(Font.system(size: size))
Spacer()
}.foregroundColor(self.color)
}
.foregroundColor(Color.white)
}
}
enum Gender: String {
case male
case female
}
struct ContentView: View {
var body: some View {
HStack{
Text("Gender")
.font(Font.headline)
VStack {
CheckboxField(
id: Gender.male.rawValue,
label: Gender.male.rawValue,
size: 14,
textSize: 14,
callback: checkboxSelected
)
CheckboxField(
id: Gender.female.rawValue,
label: Gender.female.rawValue,
size: 14,
textSize: 14,
callback: checkboxSelected
)
}
}
.padding()
}
func checkboxSelected(id: String, isMarked: Bool) {
print("\(id) is marked: \(isMarked)")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Selectable Circle, Customizable
struct SelectableCircle: View {
#Binding var isSelected: Bool
var selectionColor: Color = Color.green
var size: CGFloat = 20
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .circular)
.stroke(Color.gray, lineWidth: 2)
.background(isSelected ? selectionColor : Color.clear)
.frame(width: size, height: size, alignment: .center)
.clipShape(Circle())
.onTapGesture {
withAnimation {
isSelected.toggle()
}
}
}
}
}
You can use like this:
struct CircleChooseView_Previews: PreviewProvider {
struct CircleChooseView: View {
#State var checked = false
var body: some View {
HStack {
SelectableCircle(isSelected: $checked)
Text("Item that needs to be selected")
}
}
}
static var previews: some View {
CircleChooseView()
}
}
You should take a look to this post, it's awesome!
https://medium.com/better-programming/how-to-create-and-animate-checkboxes-in-swiftui-e428fe7cc9c1

Resources