SwiftUI withAnimation causes unexpected behaviour

Viewed 882

I noticed a strange behaviour of XCode using swiftUI's withAnimation{}.

I created the following working example:

import SwiftUI

class Block: Hashable, Identifiable {
    // Note: this needs to be a class rather than a struct
    
    var id = UUID()
    
    static func == (lhs: Block, rhs: Block) -> Bool {
        lhs.id == rhs.id
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(self.id)
    }
}

struct ContentView: View {
    @State private var blocks: [Block]
    
    init() {
        var blocks = [Block]()
        
        /// generate some blocks here
        blocks.append(Block())
        blocks.append(Block())
        blocks.append(Block())
        
        self._blocks = State(initialValue: blocks)
    }
    
    var body: some View {
        VStack {
            ForEach(blocks, id: \.self) { block in
                BlockRow(block: block) { b in
                    print("COMMENT THIS LINE OUT") // try commenting out this line -> XCODE won't build anymore
                    
                    withAnimation {
                        self.blocks.remove(at: self.blocks.firstIndex(of: b)!)
                    }
                }
            }
        }
    }
}

struct BlockRow: View {

    @State var block: Block
    var onDelete: (Block) -> Void = {_ in}
    
    var body: some View {
        Text("<Block>")
            .onTapGesture {
                print("Tap block \(block.id)")
                self.onDelete(block)
            }
    }
}

The above example works as expected, if you click on one of the blocks it get's deleted and the following blocks will nicly slide in the free position.

But XCode produces a warning here, I do not understand:

Result of call to 'withAnimation' is unused

Things get even more confusing: by just commenting out the print stamenet beforehand the withAnimation block /* print("COMMENT THIS LINE OUT") */ XCode will no longer build the project:

Failed to produce diagnostic for expression; please file a bug report

Is this a bug or am I completely off-road with my aproach?

2 Answers

The origin of the problem is the fact that self.blocks.remove(at:) returns a value that you are not handling. If you explicitly ignore that value, then your code works as expected whether or not the print statement is there.

withAnimation {
    _ = self.blocks.remove(at: self.blocks.firstIndex(of: b)!)
}

Swift has a feature that a closure with a single line of code returns the value of that statement as the return of the closure. So in this case, if you don't ignore the return value of remove(at:) it returns a Block and Block then becomes the returned type of the withAnimation closure.

withAnimation is defined like this:

func withAnimation<Result>(_ animation: Animation? = .default, _ body: () throws -> Result) rethrows -> Result

It is a generic function that takes a closure. The return type of that closure determines the type of the generic placeholder which in turn determines the return type of withAnimation itself.

So, if you don't ignore the return type of remove(at:), the withAnimation<Block> function will return a Block.

If you ignore the return value of remove(at:), the statement becomes one that has no return, (that is, it returns () or Void). Thus, the withAnimation function become withAnimation<Void> and it returns Void.

Now, because the closure to BlockRow has only a single line of code when you delete the print statement, its return value is the return value of the single statement, which is now Void:

BlockRow(block: block) { b in
    withAnimation {
        _ = self.blocks.remove(at: self.blocks.firstIndex(of: b)!)
    }
}

and this matches the type that the closure to BlockRow is expecting for its onDelete closure.

In your original code, the print statement caused the closure to BlockRow to have 2 lines of code, thus avoiding Swift's feature of using the single line of code to determine the return type of the closure. You did get a warning that you weren't using the Block that was being returned from the withAnimation<Block> function. @Asperi's answer fixed that warning by assigning the Block returned by withAnimation<Block> to _. This has the same effect as my suggested solution, but it is handling the problem one level higher instead of at the source of the problem.


Why doesn't the compiler complain that you are ignoring the return value of remove(at:)?

remove(at:) is explicitly designed to allow you to discard the returned result which is why you don't get a warning that it returns a value that you aren't handling.

@discardableResult mutating func remove(at i: Self.Index) -> Self.Element

But as you see, this lead to the confusing result you encountered. You were using remove(at:) as if it returned Void, but it in fact was returning the Block that was removed from your array. This then lead to the whole chain of events that lead to your issue.

Use the following

var body: some View {
    VStack {
        ForEach(blocks, id: \.self) { block in
            BlockRow(block: block) { b in
                _ = withAnimation {
                    self.blocks.remove(at: self.blocks.firstIndex(of: b)!)
                }
            }
        }
    }
}

Tested with Xcode 12.0 / iOS 14

Related