[SwiftUI] how to prevent unexpected crash from using onMove with SwiftUI/List/ForEach (investigation memo)

SwiftUI

In this post, I'll share my investigation result about onMove

Environment

This investigaion was done under following environment

  • macOS Big Sur 11.2.3
  • Xcode 12.4
Note
I checked on macOS Big Sur 11.2.3, maybe (most probably) behavior will be changed in later macOS.

It all started from app crash from D&D

I created one macOS app which has 2 Lists with using SwiftUI.

Those Lists has .onMove modifier for the convenience.

It was just my try that I drag&drop from List-A to List-B.

Before the drop, I thought it will be just ignored. But actual result is different. It made App crashed !

So I started to investigate how to prevent this crash.

onMove

I believe using onMove will implicitly apply onDrag and onDrop. Otherwise Drag&Drop can not start/end.

I thought controlling drag-ability/drop-ability can prevent the crash. so I started to investigate from this point.

onDrag

First I checked what type of data will be used in Drag&Drop. i.e. What type will be given from onDrag?

As always I checked Apple's documentation, but as you know for SwiftUI, it says almost nothing especially for SwiftUI on macOS.

I started to check actual code and its behavior.

After small struggles, I found identifier of the item is "com.apple.SwiftUI.listReorder".

I could not find it in publicly defined UTIs. So this might be implementation details.

After finding identifier, I tried to check the object which comes from NSItemProvider.
I tried to decode the objecth as String. (I don't have any other idea for proceeding the investigation...)

Luckily I succeeded to decode as String and decoded string was {"indexes":[0]}. looks like JSON-encoded NSIndexSet. Actually 0 was the index of dragged item in source list.

Followings are the code I used for this check. I added .onInsert to the target list.

onInsert

.onInsert(of: ["no-acceptance"]) { (index, providers) in
    for provider in providers {
        provider.loadItem(forTypeIdentifier: "com.apple.SwiftUI.listReorder", options: nil) { (data, error) in
            if let str = String(data: data as! Data, encoding: .utf8) {
                print(str)
            }
        }
    }
}
MEMO
Strangely onInsert accepted drop even provider says it will provide "com.apple.SwiftUI.listReorder". (onInsert says I'll accept only "no-acceptance".)

After this investigation, I could assume the reason why this onInsert was called in the end.

onDrop

Next, looked at onDrop.

I guess "com.apple.SwiftUI.listReorder" type and data like {"indexes":[1]} will be accepted by onMove.

Looks like it is correct assumption. (but as always only Apple knows the truth. This is another single source of truth... 🙁 )

onMove accepted following object. (other types will NOT be accepted by onMove, I checked separately.)

class for drag&drop to onMove

SwiftUIListReorder

final class SwiftUIListReorder: NSObject, NSItemProviderReading, NSItemProviderWriting, Codable {
    static var readableTypeIdentifiersForItemProvider: [String] = ["com.apple.SwiftUI.listReorder"]
    static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> SwiftUIListReorder {
        let decoder = JSONDecoder()
        let reorderData = try decoder.decode(SwiftUIListReorder.self, from: data)
        return reorderData
    }
    
    static var writableTypeIdentifiersForItemProvider: [String] = ["com.apple.SwiftUI.listReorder"]
    func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? {
        let encoder = JSONEncoder()
        do {
            let json = try encoder.encode(self)
            completionHandler(json, nil)
        } catch {
            print(error.localizedDescription)
            completionHandler(nil, error)
        }
        return nil
    }
    
    var indexes:[Int]
    init(_ values:[Int]) {
        self.indexes = values
    }
}

Note: if you want to use UTType instead of String, you need to register it also in Info.plist. without it, Xcode will give you a warning.

I used following List as source list.

List with onDrag

List {
    ForEach(texts.indices) { index in
        Text(texts[index])
            .onDrag { NSItemProvider(object: SwiftUIListReorder([index]))}
    }
}

List as target list is followings.

DropTargetList

List {
    ForEach(0..<3) { index in
        Text(String(index))
    }
    .onMove { (indexSet, dest) in
        print("onMove is called")
    }
//    .onInsert(of: [UTType.SwiftUIReorderData]) { (index, providers) in
//        print("onInsert called")
//    }
}

I could confirm that onMove "will" accept the drop because drop indicator appears when you move over to target list.

But actual drop will cause application crash with

Error
Fatal error: Attempting to insert row (destination: 1) with no associated insert action.: file SwiftUI, line 0

# destination: 1 looks the index of target list, so it will vary depends on the operation.

After removing the comment-out from above list(i.e. make onInsert effective), drop will NOT make app crashed.

My observation is followings.

Before actual drop, as indicator shows onMove thinks "I can accept this drop".
but after actual drop, onMove find "I can not accept this, so let's forward this to onInsert"

# No documentation, No criteria information. This is just my guess.

Even onMove does not know the onInsert implementation existence, onMove decides.

So if onInsert is not defined, app will crash. If onInsert is defined, onInsert will be called.
during this forwarding, no one checks the identifier compatibility. so onInsert will be called for any kind of identifier.

This might be the reason .onInsert(of: ["no-acceptance"]) will be called for identifier "com.apple.SwiftUI.listReorder".

Problem/Root cause of crash

From technical point of view, root cause of crash is "List does not have onInsert".

A little bit longer explanation:

"In some case onMove will forward the request to onInsert, even no one expects such case, List without onInsert will crash the app."

Actual problem is "there is no way to specify identifier for onMove".

Solution

Quick hack

Implement onInsert is one of the quick solution.

Even drop indicator still appears, drop will NOT make app crashed.

I believe there is no way to prohibit drop indicator with current onMove implementation.

Nice but costly solution

The root cause of the problem is "onMove can not specify identifier".

That means "we should implement onMove functionality by using onDrag/onDrop. because both onDrag/onDrop can specify identifier."

I hope this problem will be fixed soon. but until then we need to consider how to prevent unexpected crash from using SwiftUI/List/ForEach/onMove.

onDelete?

Because of UI charactaristics, onDelete is NOT affected by this issue.

Conclusion: how to prevent unexpected crash from List/ForEach/onMove

how to prevent unexpected crash from List/ForEach/onMove
  • With using onMove, identifier "com.apple.SwiftUI.listReorder" is used for Drag&Drop
  • Identifier "com.apple.SwiftUI.listReorder" is commonly used in SwiftUI List/ForEach/onMove.
  • Using common Identifier means onMove will accept drop not only from myself but also from others.
  • Without onInsert, drop from other List to onMove will make app crash.
  • With empty onInsert, we can avoid the crash. Or we need our own onMove implementation.

If you have any information, please share it with me.

Your comments are highly appreciated. please feel free to contact to twitter.

Leave a Reply

Your email address will not be published. Required fields are marked *