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()
}
}
Related
I am trying to make a simple dropdown list item in SwiftUI. This is what the code looks like:
struct SomeObject: Hashable {
var title: String = "title"
var entries: [String] = ["details", "details2", "details3"]
}
struct ContentView: View {
var data: [SomeObject] = [SomeObject()]
var body: some View {
List(data, id: \.self) { item in
HStack {
Text(item.title)
Spacer()
}
ForEach(item.entries, id: \.self) { entry in
ListItemView(entry)
}
}.listStyle(.plain)
}
}
struct ListItemView: View {
#State var expanded: Bool = false
#State var rotation: Double = 0
private let entry: String
init(_ entry: String) {
self.entry = entry
}
var body: some View {
VStack {
Divider().frame(maxWidth: .infinity)
.overlay(.black)
HStack {
Text(entry)
.fixedSize(horizontal: false, vertical: true)
Spacer()
Image(systemName: "chevron.down")
.foregroundColor(.black)
.padding()
.rotationEffect(.degrees(expanded ? 180 : 360))
.animation(.linear(duration: 0.3), value: expanded)
}.padding(.horizontal)
.padding(.vertical, 6)
if expanded {
Text("Details")
}
Divider().frame(maxWidth: .infinity)
.overlay(.black)
}
.listRowSeparator(.hidden)
.listRowInsets(.init())
.onTapGesture {
expanded.toggle()
}
}
}
For some reason when clicking on the list item the animation looks like this:
How can I make the arrow rotate on its center point without moving up or down at all?
The problem you have there is that the arrow is animated but when the hidden text appears, that vertical expansion is not animated. That contrast between an element animated and another that is not makes the chevron looks like it is not doing it properly. So, try to animate the VStack like this:
struct CombineView: View {
#State var expanded: Bool = false
#State var rotation: Double = 0
let entry: String = "Detalle"
var body: some View {
VStack {
Divider().frame(maxWidth: .infinity)
.overlay(.black)
HStack(alignment: .center) {
Text(entry)
.fixedSize(horizontal: false, vertical: true)
Spacer()
Image(systemName: "chevron.down")
.foregroundColor(.black)
.padding()
.rotationEffect(.degrees(expanded ? 180 : 360))
.animation(.linear(duration: 0.3), value: expanded)
}.padding(.horizontal)
.padding(.vertical, 6)
.background(.green)
if expanded {
Text("Details")
}
Divider().frame(maxWidth: .infinity)
.overlay(.black)
}.animation(.linear(duration: 0.3), value: expanded)//Animation added
.listRowSeparator(.hidden)
.listRowInsets(.init())
.onTapGesture {
expanded.toggle()
}
}
}
I hope this works for you ;)
I'm making a simple task app and using ForEach to populate task rows with the task information from my model. I need a way to animate my task view to open up and reveal some description text and two buttons. I want to turn from A into B on tap, and then back again on tap:
Design Image
I've tried a couple things. I successfully got a proof-of-concept rectangle animating in a test project, but there are issues. The rectangle shrinks and grows from the centre point, vs. from the bottom only. When I place text inside it, the text doesn't get hidden and it looks really bad.
struct ContentView: View {
#State var animate = false
var animation: Animation = .spring()
var body: some View {
VStack {
Rectangle()
.frame(width: 200, height: animate ? 60 : 300)
.foregroundColor(.blue)
.onTapGesture {
withAnimation(animation) {
animate.toggle()
}
}
}
}
In my main app, I was able to replace my first task view (closed) with another view that's open. This works but it looks bad and it's not really doing what I want. It's effectively replacing the view with another one using a fade animation.
ForEach(taskArrayHigh) { task in
if animate == false {
TaskView(taskTitle: task.title, category: task.category?.rawValue ?? "", complete: task.complete?.rawValue ?? "", priorityColor: Color("HighPriority"), task: task, activeDate: activeDate)
.padding(.top, 10)
.padding(.horizontal)
.onTapGesture {
withAnimation(.easeIn) {
animate.toggle()
}
}
.transition(.move(edge: .bottom))
} else if animate == true {
TaskViewOpen(task: "Grocery Shopping", category: "Home", remaining: 204, completed: 4)
.padding(.top, 10)
.padding(.horizontal)
.onTapGesture {
withAnimation(.easeIn) {
animate.toggle()
}
}
}
Is there a way to animate my original closed view to open up and reveal the description text and buttons?
You are on the right track with your .transition line you have, but you want to make sure that the container stays the same and the contents change -- right now, you're replacing the entire view.
Here's a simple example illustrating the concept:
struct ContentView: View {
#State var isExpanded = false
var body: some View {
VStack {
Text("Headline")
if isExpanded {
Text("More Info")
Text("And more")
}
}
.padding()
.frame(maxWidth: .infinity)
.transition(.move(edge: .bottom))
.background(Color.gray.cornerRadius(10.0))
.onTapGesture {
withAnimation {
isExpanded.toggle()
}
}
}
}
Since you're using it inside a ForEach, you'll probably want to abstract this into its own component, as it'll need its own #State to keep track of the expanded state as I've shown here.
Update, based on comments:
Example of using a PreferenceKey to get the height of the expandable view so that the frame can be animated and nothing fades in and out:
struct ContentView: View {
#State var isExpanded = false
#State var subviewHeight : CGFloat = 0
var body: some View {
VStack {
Text("Headline")
VStack {
Text("More Info")
Text("And more")
Text("And more")
Text("And more")
Text("And more")
Text("And more")
}
}
.background(GeometryReader {
Color.clear.preference(key: ViewHeightKey.self,
value: $0.frame(in: .local).size.height)
})
.onPreferenceChange(ViewHeightKey.self) { subviewHeight = $0 }
.frame(height: isExpanded ? subviewHeight : 50, alignment: .top)
.padding()
.clipped()
.frame(maxWidth: .infinity)
.transition(.move(edge: .bottom))
.background(Color.gray.cornerRadius(10.0))
.onTapGesture {
withAnimation(.easeIn(duration: 2.0)) {
isExpanded.toggle()
}
}
}
}
struct ViewHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout Value, nextValue: () -> Value) {
value = value + nextValue()
}
}
Using Swift 5 you can use withAnimation and have the view hidden based on state.
ExpandViewer
Has a button to show and hide the inner view
Takes in a content view
struct ExpandViewer <Content: View>: View {
#State private var isExpanded = false
#ViewBuilder let expandableView : Content
var body: some View {
VStack {
Button(action: {
withAnimation(.easeIn(duration: 0.5)) {
self.isExpanded.toggle()
}
}){
Text(self.isExpanded ? "Hide" : "View")
.foregroundColor(.white)
.frame(maxWidth: .infinity, minHeight: 40, alignment: .center)
.background(.blue)
.cornerRadius(5.0)
}
if self.isExpanded {
self.expandableView
}
}
}
}
Using the viewer
ExpandViewer {
Text("Hidden Text")
Text("Hidden Text")
}
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)
}
}
}
}
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
I'm trying to recreate the iOS 11/12 App Store with SwiftUI.
Let's imagine the "story" is the view displayed when tapping on the card.
I've done the cards, but the problem I'm having now is how to do the animation done to display the "story".
As I'm not good at explaining, here you have a gif:
Gif 1
Gif 2
I've thought of making the whole card a PresentationLink, but the "story" is displayed as a modal, so it doesn't cover the whole screen and doesn't do the animation I want.
The most similar thing would be NavigationLink, but that then obliges me to add a NavigationView, and the card is displayed like another page.
I actually do not care whether its a PresentationLink or NavigationLink or whatever as long as it does the animation and displays the "story".
Thanks in advance.
My code:
Card.swift
struct Card: View {
var icon: UIImage = UIImage(named: "flappy")!
var cardTitle: String = "Welcome to \nCards!"
var cardSubtitle: String = ""
var itemTitle: String = "Flappy Bird"
var itemSubtitle: String = "Flap That!"
var cardCategory: String = ""
var textColor: UIColor = UIColor.white
var background: String = ""
var titleColor: Color = .black
var backgroundColor: Color = .white
var body: some View {
VStack {
if background != "" {
Image(background)
.resizable()
.frame(width: 380, height: 400)
.cornerRadius(20)
} else {
RoundedRectangle(cornerRadius: 20)
.frame(width: 400, height: 400)
.foregroundColor(backgroundColor)
}
VStack {
HStack {
VStack(alignment: .leading) {
if cardCategory != "" {
Text(verbatim: cardCategory.uppercased())
.font(.headline)
.fontWeight(.heavy)
.opacity(0.3)
.foregroundColor(titleColor)
//.opacity(1)
}
HStack {
Text(verbatim: cardTitle)
.font(.largeTitle)
.fontWeight(.heavy)
.lineLimit(3)
.foregroundColor(titleColor)
}
}
Spacer()
}.offset(y: -390)
.padding(.bottom, -390)
HStack {
if cardSubtitle != "" {
Text(verbatim: cardSubtitle)
.font(.system(size: 17))
.foregroundColor(titleColor)
}
Spacer()
}
.offset(y: -50)
.padding(.bottom, -50)
}
.padding(.leading)
}.padding(.leading).padding(.trailing)
}
}
So
Card(cardSubtitle: "Welcome to this library I made :p", cardCategory: "CONNECT", background: "flBackground", titleColor: .white)
displays:
SwiftUI doesn't do custom modal transitions right now, so we have to use a workaround.
One method that I could think of is to do the presentation yourself using a ZStack. The source frame could be obtained using a GeometryReader. Then, the destination shape could be controlled using frame and position modifiers.
In the beginning, the destination will be set to exactly match position and size of the source. Then immediately afterwards, the destination will be set to fullscreen size in an animation block.
struct ContentView: View {
#State var isPresenting = false
#State var isFullscreen = false
#State var sourceRect: CGRect? = nil
var body: some View {
ZStack {
GeometryReader { proxy in
Button(action: {
self.isFullscreen = false
self.isPresenting = true
self.sourceRect = proxy.frame(in: .global)
}) { ... }
}
if isPresenting {
GeometryReader { proxy in
ModalView()
.frame(
width: self.isFullscreen ? nil : self.sourceRect?.width ?? nil,
height: self.isFullscreen ? nil : self.sourceRect?.height ?? nil)
.position(
self.isFullscreen ? proxy.frame(in: .global).center :
self.sourceRect?.center ?? proxy.frame(in: .global).center)
.onAppear {
withAnimation {
self.isFullscreen = true
}
}
}
}
}
.edgesIgnoringSafeArea(.all)
}
}
extension CGRect {
var center : CGPoint {
return CGPoint(x:self.midX, y:self.midY)
}
}
SwiftUI in iOS/tvOS 14 and macOS 11 has matchedGeometryEffect(id:in:properties:anchor:isSource:) to animate view transitions between different hierarchies.
Link to Official Documentation
Here's a minimal example:
struct SomeView: View {
#State var isPresented = false
#Namespace var namespace
var body: some View {
VStack {
Button(action: {
withAnimation {
self.isPresented.toggle()
}
}) {
Text("Toggle")
}
SomeSourceContainer {
MatchedView()
.matchedGeometryEffect(id: "UniqueViewID", in: namespace, properties: .frame, isSource: !isPresented)
}
if isPresented {
SomeTargetContainer {
MatchedTargetView()
.matchedGeometryEffect(id: "UniqueViewID", in: namespace, properties: .frame, isSource: isPresented)
}
}
}
}
}