Search code examples
swiftuiswiftui-list

Move Cell to the bottom of the List when completed in SwiftUI IOS 16


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

enter image description here

("selected" -> means tap on the circle image to turn it to checkmark.fill image)

Example.

  • When I checked Item 2 it went to the bottom and when I checked Item 3 it was above Item 2.
  • When I unchecked Item 2 after that It went above the last checked item that is Item 3 and if it unchecked then it will go to the bottom of unchecked items.

My Boolean Conditions

I have two Boolean conditions and three states

  1. isLooking: false, isFound: false -> Image(systemName: "circle") -> still looking for item
  2. isLooking: true, isFound: false -> Image(systemName: "multiply.circle") -> Item unavailable
  3. isLooking: true, isFound: true -> Image(systemName: "checkmark.circle") -> found the item

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

ShoppingListItemModel.swift

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

ShoppingListItemViewModel.swift

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

ShoppingListItemRowView.swift

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

ShoppingListItemView.swift

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.


Solution

  • 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:

    • I noticed that you wanted untouched items to appear after touched items, so this is why the date of last update is an optional that starts nil.
    • If you wanted to change the logic for the ordering then you just have to change the way the < operator is implemented.
    • If you don't like the way that the model includes presentation logic (for the sorting) then you could implement a separate comparator function and use sorted(by:), instead of making the items Comparable.
    • The reason for using @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.