I have been struggling with a problem in my code.
I am trying to make a an Shopping List App using SwiftUI IOS 16.
I have added a demo gif to show which functionality I want to add to my List. I want to move the cell which is completed to the bottom of the list , if a second item is completed then the item will be above the completed cell before it. If the item is unchecked then it will move above all the checked items
("selected" -> means tap on the circle image to turn it to checkmark.fill image)
Example.
I have two Boolean conditions and three states
I want all the multiply.circles together at the bottom then all the checkmark.circles together above them and lastly all the circles at the top
struct ShoppingListItemModel: Identifiable {
let id: String
let itemName: String
let itemBrandName: String
let itemCount: Int
let isLooking: Bool
let isFound: Bool
init(id: String = UUID().uuidString, itemName: String, itemBrandName: String, itemCount: Int, isLooking: Bool, isFound: Bool) {
self.id = id
self.itemName = itemName
self.itemBrandName = itemBrandName
self.itemCount = itemCount
self.isLooking = isLooking
self.isFound = isFound
}
func updateIsLooking() -> ShoppingListItemModel{
return ShoppingListItemModel(id: id, itemName: itemName, itemBrandName: itemBrandName, itemCount: itemCount, isLooking: false, isFound: false)
}
func updateIsFound() -> ShoppingListItemModel{
return ShoppingListItemModel(id: id, itemName: itemName, itemBrandName: itemBrandName, itemCount: itemCount, isLooking: true, isFound: true)
}
func updateIsNotFound() -> ShoppingListItemModel{
return ShoppingListItemModel(id: id, itemName: itemName, itemBrandName: itemBrandName, itemCount: itemCount, isLooking: true, isFound: false)
}
}
class ShoppingListItemViewModel: ObservableObject {
@Published var shoppingListItems: [ShoppingListItemModel] = []
init(){ testingValues() }
func testingValues (){
let newShoppingListItems = [
ShoppingListItemModel(itemName: "Item 1", itemBrandName: "GV", itemCount: 1, isLooking: false, isFound: false),
ShoppingListItemModel(itemName: "Item 2", itemBrandName: "GV", itemCount: 1, isLooking: false, isFound: false),
ShoppingListItemModel(itemName: "Item 3", itemBrandName: "GV", itemCount: 1, isLooking: false, isFound: false)
]
shoppingListItems.append(contentsOf: newShoppingListItems)
}
func deleteItem(indexSets: IndexSet){
shoppingListItems.remove(atOffsets: indexSets)
}
func moveItems(from: IndexSet, to: Int){
shoppingListItems.move(fromOffsets: from, toOffset: to)
}
func addItems(itemName: String, itemBrandName: String, itemCount: Int){
let newListItem = ShoppingListItemModel(itemName: itemName, itemBrandName: itemBrandName, itemCount: 1, isLooking: false, isFound: false)
shoppingListItems.append(newListItem)
}
func updateIsLooking(shoppingListItem : ShoppingListItemModel){
if let index = shoppingListItems.firstIndex(where: { $0.id == shoppingListItem.id}){
shoppingListItems[index] = shoppingListItem.updateIsLooking()
}
}
func updateIsFound(shoppingListItem : ShoppingListItemModel){
if let index = shoppingListItems.firstIndex(where: { $0.id == shoppingListItem.id}){
shoppingListItems[index] = shoppingListItem.updateIsFound()
}
}
func updateIsNotFound(shoppingListItem : ShoppingListItemModel){
if let index = shoppingListItems.firstIndex(where: { $0.id == shoppingListItem.id}){
shoppingListItems[index] = shoppingListItem.updateIsNotFound()
}
}
}
import SwiftUI
struct ShoppingListItemRowView: View {
@EnvironmentObject var shoppingListItemVM: ShoppingListItemViewModel
var shoppingListItem: ShoppingListItemModel
@State var showSheet: Bool = false
var body: some View{
HStack(){
Image(systemName: shoppingListItem.isLooking ? ( shoppingListItem.isFound ? "checkmark.circle" : "multiply.circle" ) :"circle")
.resizable()
.scaledToFit()
.frame(width: 30, height: 30)
.foregroundColor(shoppingListItem.isLooking ? ( shoppingListItem.isFound ? Color.green : Color.red ) : Color.theme.textPrimaryColor)
.padding(.leading, 15)
.onTapGesture {
showSheet.toggle()
}
VStack(alignment:.leading) {
HStack {
Text(shoppingListItem.itemName)
Text("- "+shoppingListItem.itemBrandName)
}
.font(.title2)
Text("Qty: \(shoppingListItem.itemCount)")
.font(.body)
}
.padding(.leading, 10)
Spacer()
}
.padding(.vertical, 10)
.frame(width: .infinity)
.background(Color.theme.backgroundSecondaryColor)
.cornerRadius(10)
.sheet(isPresented: $showSheet) {
SheetShowView(shoppingListItem: shoppingListItem)
.presentationDetents([.height(200)])
}
}
}
//MARK: - Preview
struct ShoppingListItemRowView_Previews: PreviewProvider {
static var previews: some View {
NavigationStack{
ShoppingListItemRowView(shoppingListItem: dev.shoppingListItem)
}
.environmentObject(ShoppingListItemViewModel())
.environmentObject(ShoppingListNameViewModel())
}
}
//MARK: - Sheet Show View
struct SheetShowView: View {
@EnvironmentObject var shoppingListItemVM: ShoppingListItemViewModel
var shoppingListItem: ShoppingListItemModel
@Environment (\.dismiss) private var dismiss
var body: some View{
VStack{
Button {
shoppingListItemVM.updateIsLooking(shoppingListItem: shoppingListItem)
dismiss()
} label: {
Text("Still Looking")
.foregroundColor(Color.theme.textSecondaryColor)
}
.padding(10)
.foregroundColor(.black)
Button {
shoppingListItemVM.updateIsFound(shoppingListItem: shoppingListItem)
dismiss()
} label: {
Text("Found")
.foregroundColor(Color.theme.textSecondaryColor)
}
.padding(10)
.foregroundColor(.black)
Button {
shoppingListItemVM.updateIsNotFound(shoppingListItem: shoppingListItem)
dismiss()
} label: {
Text("Not Found")
.foregroundColor(Color.theme.textSecondaryColor)
}
.padding(10)
.foregroundColor(.black)
}
}
}
struct ShoppingListItemView: View {
@EnvironmentObject var shoppingListItemVM: ShoppingListItemViewModel
var body: some View {
ZStack(alignment: .bottomTrailing) {
Color.theme.backgroundPrimaryColor
.ignoresSafeArea()
//List of items in the Shopping List Name
List {
ForEach(shoppingListItemVM.shoppingListItems) { item in
ShoppingListItemRowView( shoppingListItem: item)
//Separator Line between each cell
.listRowSeparator(.hidden)
//Distance between each cell
.listRowInsets(.init(top: 5, leading: 20, bottom: 5, trailing: 20))
}
.onMove(perform: shoppingListItemVM.moveListItems)
.onDelete(perform: shoppingListItemVM.deleteListItems)
}
//Style of the list
.listStyle(.plain)
// Button to navigate to a view to add new items to the list
NavigationLink {
// Reference to View
ShoppingListItemAddView()
} label: {
// Look of the Button
CustomAddButtonView()
}
.padding(50)
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
}
}
}
//MARK: - Preview
struct ShoppingListItemView_Previews: PreviewProvider {
static var previews: some View {
NavigationStack {
ShoppingListItemView()
}
.environmentObject(ShoppingListItemViewModel())
.environmentObject(ShoppingListNameViewModel())
}
}
I am updating my circle from the sheet show view. I really hope someone can help me out with this logic.
Unfortunately, your code doesn't compile because there are things missing (Color.theme, ShoppingListItemAddView, CustomAddButtonView). In any case, I would approach it differently.
I would suggest that your items implement ÒbservableObject
, because this makes it easier to bind the View of an item to the state of the item. If the items also implement Comparable
then you can sort them. The algorithm for comparing two items should consider the time of last update, which I think is the key thing that you were missing.
The only thing left to resolve is to make sure the view of all items is refreshed whenever an underlying item changes. You tried to achieve this by making the collection itself observable. In the example below I have used a separate observable counter to achieve it.
The following is a greatly simplified example which behaves the same way as your animated gif. Some other notes follow after it.
import SwiftUI
final class MyCounter: ObservableObject {
@Published private var n = 0
var val: Int {
n
}
func increment() {
n += 1
}
}
final class MyItem: Identifiable, Comparable, ObservableObject {
let id: Int
let name: String
private let counter: MyCounter
private var isFound = false
@Published private var timeOfLastUpdate = Date.now
init(
originalOrdering: Int,
name: String,
counter: MyCounter
) {
self.id = originalOrdering
self.name = name
self.counter = counter
}
var isChecked: Bool {
isFound
}
func toggleCheckedState() {
isFound.toggle()
// Notify my observers by updating the time of last update
timeOfLastUpdate = Date.now
// Notify observers of the counter by incrementing the counter
counter.increment()
}
static func == (lhs: MyItem, rhs: MyItem) -> Bool {
lhs.id == rhs.id
}
static func < (lhs: MyItem, rhs: MyItem) -> Bool {
let result: Bool
// Compare state first
if lhs.isFound == rhs.isFound {
// Compare times of last update. These are only
// expected to be equal if both are nil
if lhs.timeOfLastUpdate == rhs.timeOfLastUpdate {
// Just use original ordering
result = lhs.id < rhs.id
} else if let lhsTimeOfLastUpdate = lhs.timeOfLastUpdate {
if let rhsTimeOfLastUpdate = rhs.timeOfLastUpdate {
// The ordering depends on whether the items are
// checked or not. For checked items, the more
// recently updated comes first. For unchecked
// items, the more recently updated comes last
result = lhs.isFound
? lhsTimeOfLastUpdate > rhsTimeOfLastUpdate
: lhsTimeOfLastUpdate < rhsTimeOfLastUpdate
} else {
// The lhs was updated, the rhs is still nil
result = false
}
} else {
// The lhs is still nil, rhs was updated
result = true
}
} else {
// Unchecked items come before checked items
result = !lhs.isFound
}
return result
}
}
struct MyItemView: View {
@ObservedObject private var item: MyItem
/// The size for the check button
@ScaledMetric(relativeTo: .body) private var size: CGFloat = 20
/// The line width for stroking the unselected button
@ScaledMetric(relativeTo: .body) private var lineWidth: CGFloat = 1.5
init(item: MyItem) {
self.item = item
}
@ViewBuilder
private var checkButton: some View {
if item.isChecked {
// Show a round shape with a transparent tick
Image(systemName: "checkmark.circle.fill")
.resizable()
.scaledToFill()
.foregroundColor(.orange)
.frame(width: size + lineWidth, height: size + lineWidth)
} else {
// Just show an empty ring
Circle()
.stroke(lineWidth: lineWidth)
.foregroundColor(.gray)
.frame(width: size, height: size)
.padding(lineWidth / 2)
}
}
var body: some View {
HStack {
checkButton
.onTapGesture {
item.toggleCheckedState()
}
Text(item.name)
}
}
}
struct ContentView: View {
@StateObject private var counter: MyCounter
private let items: [MyItem]
init() {
let counter = MyCounter()
self._counter = StateObject(wrappedValue: counter)
self.items = [
MyItem(originalOrdering: 1, name: "Item 1", counter: counter),
MyItem(originalOrdering: 2, name: "Item 2", counter: counter),
MyItem(originalOrdering: 3, name: "Item 3", counter: counter),
MyItem(originalOrdering: 4, name: "Item 4", counter: counter),
MyItem(originalOrdering: 5, name: "Item 5", counter: counter)
]
}
var body: some View {
List(items.sorted()) { item in
MyItemView(item: item)
}
.animation(.easeInOut, value: counter.val)
}
}
Other notes:
sorted(by:)
, instead of making the items Comparable
.@ScaledMetric
for the values used to style the check button is so that the buttons grow larger when the user chooses to use larger fonts. This is code I am already using in my project, so I thought I might as well throw it in.