Open19

SwiftUI: Listの.moveDisabledの動きがおかしい件

kabeyakabeya

1つのリストの中に、グループとグループ内アイテムを入れて、グループ内アイテムはグループをまたいで移動できる、というような画面を作るとします。

例えば以下のようになります。

struct ListItem: Hashable {
    var text: String
    var isMovable: Bool
}

struct ContentView: View {
    @State var items: [ListItem] = [
        ListItem(text: "Group A", isMovable: false),
        ListItem(text: "Item 1", isMovable: true),
        ListItem(text: "Item 2", isMovable: true),
        ListItem(text: "Item 3", isMovable: true),
        ListItem(text: "Group B", isMovable: false),
        ListItem(text: "Item 4", isMovable: true),
        ListItem(text: "Item 5", isMovable: true),
    ]
    var body: some View {
        NavigationStack {
            List {
                ForEach(items, id: \.self) { item in
                    Text(item.text)
                        .font(item.isMovable ? .body : .headline)
                        .foregroundColor(item.isMovable ? .primary : .red)
                        .moveDisabled(!item.isMovable)
                }
                .onMove { indexSet, index in
                    items.move(fromOffsets: indexSet, toOffset: index)
                }
            }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    EditButton()
                }
            }
        }
    }
}

編集中の画面は以下のようになります。
(黄色の丸数字は後から付け加えたものです)

.moveDisabled(true)になる行は、移動のマークが出てないことが分かります。

実際移動させてみると、以下のようになります。

移動先→
Item 1 ×:想定通り - - ○:想定通り ○:想定通り ×:想定外 ○:想定通り ○:想定通り
Item 2 ×:想定通り ○:想定通り - - ○:想定通り ×:想定外 ○:想定通り ○:想定通り
Item 3 ×:想定通り ○:想定通り ○:想定通り - - ×:想定外 ○:想定通り ○:想定通り
Item 4 ×:想定通り ○:想定通り ○:想定通り ○:想定通り ×:想定外 - - ○:想定通り
Item 5 ×:想定通り ○:想定通り ○:想定通り ○:想定通り ×:想定外 ○:想定通り - -

Group Aのアイテム(Item 1〜3)がGroup Bの直下⑥には入らないので、どれもGroup Bの直下⑥には入らないのかと思いきや、Item 5はGroup Bの直下⑥に入る、というような動きをします。
同様に、Group Bの直上⑤には入るのかと思いきやItem 4,5は入らないという動きをします。

ちなみに、Item 1〜3をGroup Bに全部移動してしまうと、もうGroup Aには何も入れられなくなります。同様に、Group Aに全部移動してしまうと、Group Bには何も入れられなくなります。

下から上に動かす場合と上から下に動かす場合とで入れられる場所が異なっているので、List(じゃないかForEachか)の.moveDisabledの判定の際の添字数え方バグのような気がします。

iOS 16.4.1(a)で発生します。
それ以外でどうなのかは分かりません。

どちらにせよ今のこの状態だと、.moveDisabledは最初の行か最後の行を止めるのに使えます、ぐらいのかなり限定的な使い方しかできないと思います。
表の途中には入れられません。

kabeyakabeya

どちらにせよ今のこの状態だと、.moveDisabledは最初の行か最後の行を止めるのに使えます、ぐらいのかなり限定的な使い方しかできないと思います。

というか、もし「最初の行か最後の行を止めるのに使う」のだとしたら、今の動きしかない気がしてきました。

表の途中に移動できない行がある、というようなイメージの場合、toOffsetのほうの判定に.moveDisabledを使っているのはおかしいのですが、最初の行(固定した場合)の前や最後の行(固定した場合)の後ろに行を移動させない、と考えると、多少納得です。

本当は、移動先をtoOffsetのような行そのものの位置ではなくて、after:/before:のような行間を指すようにしたり、.moveDisabledではなくて、この行間に移動できるのかどうかで制御できれば良いのだろうと思います。
ですがそうしようとすると、行間を表すオブジェクトがなく、オブジェクトの状態をビューに反映させるというSwiftUIの設計に合いません。

まあそういうことかなと思います。

kabeyakabeya

いま作ろうとしているものの場合、Group AとGroup Bを入れ替えられる想定ではなかったのですが、よくよく考えると別に入れ替わっても良い気がしており、そうなると.moveDisabledは使わなくて良さそうです。

自前で実装することになるのかと思って憂鬱になるところでした。
危なかった!

kabeyakabeya

先頭行がGroup外に出てしまうと問題なので、先頭行だけはGroup A固定、というようにしないとダメですね。

kabeyakabeya

なんだろう、ListってeditMode = .activeでなくてもドラッグで移動できますね。
.onMoveがある場合)

どうやって止めるのかしら?

kabeyakabeya

.onMoveのクロージャ内で「editModeを確認して、.activeでなければ配列の入れ替えをしない」としても、画面上はドラッグ結果が反映された状態になってしまい、そのうえで、再描画がかかるとドラッグ結果が反映されていない状態(元に戻る)になってしまいます。

タップの動作とドラッグの動作が近いため、タップしたつもりがドラッグになってしまってタップにならないのは結構イライラします。

kabeyakabeya

似たような違う話になりますが、.moveDisabled/.deleteDisabledがリセットされなくて困っています。

struct TestView: View {
    @State var items: [String] = [
        "Item 1",
        "Item 2",
        "Item 3"
    ]
    
    var body: some View {
        VStack {
            HStack {
                EditButton()
                Button("Add") {
                    items.append("Item \(items.count + 1)")
                }
            }
            List {
                ForEach(items.indices, id: \.self) { idx in
                    Text(items[idx])
                        .moveDisabled(idx == items.count - 1)
                        .deleteDisabled(idx == items.count - 1)
                }
                .onDelete { indexSet in
                    items.remove(atOffsets: indexSet)
                }
                .onMove { indexSet, index in
                    items.move(fromOffsets: indexSet, toOffset: index)
                }
            }
            
        }
    }
}

最下行は移動できない・削除できない、とするイメージです。
Addを押すと行が最下行として追加されます。
追加された行は移動できない・削除できない、でOKなんですが、いままで最下行だった行は移動できる・削除できるになって欲しい。
ですが、そうならず当初の.moveDisabled/.deleteDisabledを引き継いでしまうようです。

なにか強制的に再構築する方法があるのでしょうか。

kabeyakabeya

クリアボタンや3行追加ボタンを追加してみました。
3行追加すると2行は移動可能、最後の1行は移動不可になります。
追加の処理が一連で行われたあと、再描画がかかって.moveDisabled/.deleteDisabledが設定されるような動きですが、一回セットされた.moveDisabled/.deleteDisabledはリセットされません。
クリアして改めて追加すると、前の状態はリセットされます。

struct TestView: View {
    @State var items: [String] = [
        "Item 1",
        "Item 2",
        "Item 3"
    ]
    
    var body: some View {
        VStack {
            HStack {
                EditButton()
                Button("Add") {
                    items.append("Item \(items.count + 1)")
                }
                Button("Add 3 items") {
                    items.append("Item \(items.count + 1)")
                    items.append("Item \(items.count + 1)")
                    items.append("Item \(items.count + 1)")
                }
                Button("Clear") {
                    items.removeAll()
                }
            }
            .buttonStyle(.bordered)

            List {
                ForEach(items.indices, id: \.self) { idx in
                    Text(items[idx])
                        .moveDisabled(idx == items.count - 1)
                        .deleteDisabled(idx == items.count - 1)
                }
                .onDelete { indexSet in
                    items.remove(atOffsets: indexSet)
                }
                .onMove { indexSet, index in
                    items.move(fromOffsets: indexSet, toOffset: index)
                }
            }
            
        }
    }
}

どうすっかな。

kabeyakabeya

追加する処理に遅延を入れるとまあうまく行きますが、そういうことじゃないよね…

                Button("Add") {
                    var clone = [String](items)
                    items.removeAll()
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.001) {
                        clone.append("Item \(clone.count + 1)")
                        items.append(contentsOf: clone)
                    }
                }
                Button("Add 3 items") {
                    var clone = [String](items)
                    items.removeAll()
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.001) {
                        clone.append("Item \(clone.count + 1)")
                        clone.append("Item \(clone.count + 1)")
                        clone.append("Item \(clone.count + 1)")
                        items.append(contentsOf: clone)
                    }
                }

.now() + 0.001の部分をなくしたり、0.0001とかまで短くするとうまく行きませんね(行くときもあります)。

kabeyakabeya

asyncAfter(deadline: .now() + 0.001)とかは、マシンの性能や負荷に依存してうまくいったりいかなかったりするので、NSConditionを使ってリストを空にする処理が行われるのを待つようにしました。

流れとしては以下のようになります。

  1. ボタンを押す
  2. リストのソースの内容をいったんコピーしておく
  3. リストのソースをクリアする
  4. 「リストの表示がいったん更新されるまで待ってからリストのソースを更新する処理」を非同期ディスパッチキューに入れる(ただし、リストのソースが元々空の場合、リストのソースをクリアしたところでソースの状態が変わったわけでなくリストの表示は更新されず待っても通知はこないため、最初からソースを同期的に更新する)
  5. いったんボタンの処理は終了
  6. リストのソースがクリアされたのでリストに更新がかかる
  7. リストの更新処理の中で「リストの表示が更新されたよ」通知を送る
  8. リストがいったん空になる
  9. 通知を受けて非同期ディスパッチ処理の待機が解除され、リストのソースが更新される
  10. リストのソースが更新されたので、リストに再度更新がかかる

これでうまく動いているように見えたのですが、こうやって書き出してみると、これViewの更新中に、リストのソースを非同期に書き換えて大丈夫なのかと心配になりますね。
ちなみに、DispatchQueueを作らずにDispatchQueue.mainで実行しようとするとダメでした。当たり前か。リストの描画と待つ処理とが同じスレッドで動くのでデッドロックを起こします。

class ListMonitor {
    var isListUpdated = false
    let condition = NSCondition()
    let queue = DispatchQueue(label: "ListMonitor")
    
    func waitUpdatingThenExec(_ execution: @escaping () -> (Void)) {
        do {
            self.condition.lock()
            defer {
                self.condition.unlock()
            }
            self.isListUpdated = false
        }
        queue.async {
            self.condition.lock()
            defer {
                self.condition.unlock()
            }
            while (!self.isListUpdated) {
                self.condition.wait()
            }
            execution()
        }
    }
    
    func signalUpdating() {
        self.condition.lock()
        defer {
            self.condition.unlock()
        }
        self.isListUpdated = true
        self.condition.signal()
    }
}


struct TestView: View {
    @State var items: [String] = [
        "Item 1",
        "Item 2",
        "Item 3"
    ]
    let listMonitor = ListMonitor()
    
    var body: some View {
        VStack {
            HStack {
                EditButton()
                Button("Add") {
                    var clone = [String](items)
                    items.removeAll()
                    let execution = {
                        clone.append("Item \(clone.count + 1)")
                        items.append(contentsOf: clone)
                    }
                    if clone.count == 0 {
                        execution()
                    }
                    else {
                        listMonitor.waitUpdatingThenExec(execution)
                    }
                }
                Button("Add 3 items") {
                    var clone = [String](items)
                    items.removeAll()
                    let execution = {
                        clone.append("Item \(clone.count + 1)")
                        clone.append("Item \(clone.count + 1)")
                        clone.append("Item \(clone.count + 1)")
                        items.append(contentsOf: clone)
                    }
                    if clone.count == 0 {
                        execution()
                    }
                    else {
                        listMonitor.waitUpdatingThenExec(execution)
                    }
                }
                Button("Clear") {
                    items.removeAll()
                }
            }
            .buttonStyle(.bordered)

            List {
                let _ = listMonitor.signalUpdating()
                ForEach(items.indices, id: \.self) { idx in
                    Text(items[idx])
                        .moveDisabled(idx == items.count - 1)
                        .deleteDisabled(idx == items.count - 1)
                }
                .onDelete { indexSet in
                    items.remove(atOffsets: indexSet)
                }
                .onMove { indexSet, index in
                    items.move(fromOffsets: indexSet, toOffset: index)
                }
            }
            
        }
    }
}
kabeyakabeya

この処理はいったんリストの表示までクリアされるのを待つので、どうしても更新の際に画面がちらつきます。
なにか再描画を抑える方法があればいいのかも知れませんが、それはそれでロクなことにならない気もします。

kabeyakabeya

あと、これは単に自分の中で@Stateを書き換えてるだけだからいいんですが、外からソースを持ってくるならどうするのかという最も大きい問題があります。

どうすっかな。

kabeyakabeya

@Publishedの変数を同じように更新しようとしたら
Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
っていうエラーが出ますね。

kabeyakabeya

execution()の部分をDisptachQueue.mainで実行するようにしたら良さそうです。

class ListMonitor {
    var isListUpdated = false
    let condition = NSCondition()
    let queue = DispatchQueue(label: "ListMonitor")
    
    func waitUpdatingThenExec(_ execution: @escaping () -> (Void)) {
        do {
            self.condition.lock()
            defer {
                self.condition.unlock()
            }
            self.isListUpdated = false
        }
        queue.async {
            self.condition.lock()
            defer {
                self.condition.unlock()
            }
            while (!self.isListUpdated) {
                self.condition.wait()
            }
            DispatchQueue.main.async {
                execution()
            }
        }
    }
    
    func signalUpdating() {
        self.condition.lock()
        defer {
            self.condition.unlock()
        }
        self.isListUpdated = true
        self.condition.signal()
    }
}
kabeyakabeya

ビューの外部にソースを持たせ、外のビューでも更新するような仕組みを試してみました。
外部ソースのリスト要素をリセットしたりするのは現実的でないため、@Stateでリストの要素数を保持し、それを0にしたり、外部ソースのリスト要素数に戻したりすることで同様の動きにできないか試しています。

外のビューで更新されても@Stateの要素数に反映されないので、.onChangeで監視して反映しています。自ビューのボタン処理はシンプルなものに変更しても、.onChangeで反映されます。

なぜか.onChange内で.signalをしてやらないといけないのですが、そうしないとダメな理由はよく分かっていません。これがないとリストの再描画で.signalされても.waitしたままになってしまいます。

class ListMonitor {
    var isListUpdated = false
    let condition = NSCondition()
    let queue = DispatchQueue(label: "ListMonitor")
    
    func waitUpdatingThenExec(_ execution: @escaping () -> (Void)) {
        do {
            self.condition.lock()
            defer {
                self.condition.unlock()
            }
            self.isListUpdated = false
        }
        queue.async {
            self.condition.lock()
            defer {
                self.condition.unlock()
            }
            while (!self.isListUpdated) {
                self.condition.wait()
            }
            DispatchQueue.main.async {
                execution()
            }
        }
    }
    
    func signalUpdating() {
        self.condition.lock()
        defer {
            self.condition.unlock()
        }
        self.isListUpdated = true
        self.condition.signal()
    }
}

class TestModel: ObservableObject {
    @Published var items: [String] = [
        "Item 1",
        "Item 2",
        "Item 3"
    ]
}

struct TestView: View {
    let listMonitor = ListMonitor()
    @ObservedObject var model: TestModel
    @State var itemCount: Int
    
    init(_ model: TestModel) {
        self._model = ObservedObject(initialValue: model)
        self._itemCount = State(initialValue: model.items.count)
    }
    
    var body: some View {
        VStack {
            HStack {
                EditButton()
                Button("Add") {
                        self.model.items.append("Item \(self.model.items.count + 1)")
                }
                Button("Add 3 items") {
                        self.model.items.append("Item \(self.model.items.count + 1)")
                        self.model.items.append("Item \(self.model.items.count + 1)")
                        self.model.items.append("Item \(self.model.items.count + 1)")
                }
                Button("Clear") {
                    self.model.items.removeAll()
                    self.itemCount = 0
                }
            }
            .buttonStyle(.bordered)

            List {
                let _ = listMonitor.signalUpdating()
                ForEach(0 ..< self.itemCount, id: \.self) { idx in
                    Text(self.model.items[idx])
                        .moveDisabled(idx == self.model.items.count - 1)
                        .deleteDisabled(idx == self.model.items.count - 1)
                }
                .onDelete { indexSet in
                    self.model.items.remove(atOffsets: indexSet)
                    self.itemCount = self.model.items.count
                }
                .onMove { indexSet, index in
                    self.model.items.move(fromOffsets: indexSet, toOffset: index)
                }
            }
            
        }
        .onChange(of: self.model.items) { newValue in
            if (self.itemCount != newValue.count) {
                self.itemCount = 0
                let execution = {
                    self.itemCount = self.model.items.count
                }
                if self.model.items.isEmpty {
                    execution()
                }
                else {
                    listMonitor.waitUpdatingThenExec(execution)
                }
                self.listMonitor.signalUpdating()
            }
        }
    }
}

struct TestFrameView: View {
    @StateObject var model: TestModel = TestModel()
    var body: some View {
        VStack {
            TestView(model)
            Button("Add outside") {
                model.items.append("outside \(model.items.count + 1)")
            }
        }
    }
}
kabeyakabeya

もっと良い方法が見つかりました。
リストの要素に.id()モディファイアで同じか変わったか付けてやるという方法です。

struct TestView: View {
    @ObservedObject var model: TestModel
    
    init(_ model: TestModel) {
        self._model = ObservedObject(initialValue: model)
    }
    
    var body: some View {
        VStack {
            HStack {
                EditButton()
                Button("Add") {
                        self.model.items.append("Item \(self.model.items.count + 1)")
                }
                Button("Add 3 items") {
                        self.model.items.append("Item \(self.model.items.count + 1)")
                        self.model.items.append("Item \(self.model.items.count + 1)")
                        self.model.items.append("Item \(self.model.items.count + 1)")
                }
                Button("Clear") {
                    self.model.items.removeAll()
                }
            }
            .buttonStyle(.bordered)

            List {
                ForEach(self.model.items.indices, id: \.self) { idx in
                    let disabled = (idx == self.model.items.count - 1)
                    Text(self.model.items[idx])
                        .id("list item \(idx * 10 + (disabled ? 1 : 0))")
                        .moveDisabled(disabled)
                        .deleteDisabled(disabled)
                }
                .onDelete { indexSet in
                    self.model.items.remove(atOffsets: indexSet)
                }
                .onMove { indexSet, index in
                    self.model.items.move(fromOffsets: indexSet, toOffset: index)
                }
            }
            
        }
    }
}

リストの要素に対して、インデックスに応じたidを振るのですが、その際、下一桁を編集可能かどうか示す値にします。
編集可能かどうかが変わるとidも変わり、リストは要素が変わったと思って再構築します。

これシンプルでいいですね。

kabeyakabeya

編集可能かどうかが変わるとidも変わり、リストは要素が変わったと思って再構築します。

なんかうまく行かないケースもありますね。
なにかな…

kabeyakabeya

Listの側に.id(self.model.items.count)のように、要素が変わるとidも変わるようなidを付けると、要素数が増えたときに全体が再構築され、.moveDisabled/.deleteDisabledも適切に設定されますね。

リスト全体の再構築になってしまうので、コストが高くつくケースもありそうです。

kabeyakabeya

Listの側に.id(self.model.items.count)のように、要素が変わるとidも変わるようなidを付けると、要素数が増えたときに全体が再構築され、.moveDisabled/.deleteDisabledも適切に設定されますね。

上記では、.onMoveで順序が入れ替わっただけのとき要素数は変わらないので、.deleteDisabledが位置そのままで残ってしまいます。
.id(self.model.items.hashValue)のようなものが良いかも知れません。この場合、.itemsの中身がHashableである必要があります。