SwiftUI: Listの.moveDisabledの動きがおかしい件
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
は最初の行か最後の行を止めるのに使えます、ぐらいのかなり限定的な使い方しかできないと思います。
表の途中には入れられません。
どちらにせよ今のこの状態だと、.moveDisabledは最初の行か最後の行を止めるのに使えます、ぐらいのかなり限定的な使い方しかできないと思います。
というか、もし「最初の行か最後の行を止めるのに使う」のだとしたら、今の動きしかない気がしてきました。
表の途中に移動できない行がある、というようなイメージの場合、toOffset
のほうの判定に.moveDisabled
を使っているのはおかしいのですが、最初の行(固定した場合)の前や最後の行(固定した場合)の後ろに行を移動させない、と考えると、多少納得です。
本当は、移動先をtoOffset
のような行そのものの位置ではなくて、after:
/before:
のような行間を指すようにしたり、.moveDisabled
ではなくて、この行間に移動できるのかどうかで制御できれば良いのだろうと思います。
ですがそうしようとすると、行間を表すオブジェクトがなく、オブジェクトの状態をビューに反映させるというSwiftUIの設計に合いません。
まあそういうことかなと思います。
いま作ろうとしているものの場合、Group AとGroup Bを入れ替えられる想定ではなかったのですが、よくよく考えると別に入れ替わっても良い気がしており、そうなると.moveDisabled
は使わなくて良さそうです。
自前で実装することになるのかと思って憂鬱になるところでした。
危なかった!
先頭行がGroup外に出てしまうと問題なので、先頭行だけはGroup A固定、というようにしないとダメですね。
なんだろう、List
ってeditMode = .active
でなくてもドラッグで移動できますね。
(.onMove
がある場合)
どうやって止めるのかしら?
.onMove
のクロージャ内で「editMode
を確認して、.active
でなければ配列の入れ替えをしない」としても、画面上はドラッグ結果が反映された状態になってしまい、そのうえで、再描画がかかるとドラッグ結果が反映されていない状態(元に戻る)になってしまいます。
タップの動作とドラッグの動作が近いため、タップしたつもりがドラッグになってしまってタップにならないのは結構イライラします。
似たような違う話になりますが、.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を引き継いでしまうようです。
なにか強制的に再構築する方法があるのでしょうか。
クリアボタンや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)
}
}
}
}
}
どうすっかな。
追加する処理に遅延を入れるとまあうまく行きますが、そういうことじゃないよね…
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とかまで短くするとうまく行きませんね(行くときもあります)。
asyncAfter(deadline: .now() + 0.001)
とかは、マシンの性能や負荷に依存してうまくいったりいかなかったりするので、NSCondition
を使ってリストを空にする処理が行われるのを待つようにしました。
流れとしては以下のようになります。
- ボタンを押す
- リストのソースの内容をいったんコピーしておく
- リストのソースをクリアする
- 「リストの表示がいったん更新されるまで待ってからリストのソースを更新する処理」を非同期ディスパッチキューに入れる(ただし、リストのソースが元々空の場合、リストのソースをクリアしたところでソースの状態が変わったわけでなくリストの表示は更新されず待っても通知はこないため、最初からソースを同期的に更新する)
- いったんボタンの処理は終了
- リストのソースがクリアされたのでリストに更新がかかる
- リストの更新処理の中で「リストの表示が更新されたよ」通知を送る
- リストがいったん空になる
- 通知を受けて非同期ディスパッチ処理の待機が解除され、リストのソースが更新される
- リストのソースが更新されたので、リストに再度更新がかかる
これでうまく動いているように見えたのですが、こうやって書き出してみると、これ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)
}
}
}
}
}
この処理はいったんリストの表示までクリアされるのを待つので、どうしても更新の際に画面がちらつきます。
なにか再描画を抑える方法があればいいのかも知れませんが、それはそれでロクなことにならない気もします。
あと、これは単に自分の中で@State
を書き換えてるだけだからいいんですが、外からソースを持ってくるならどうするのかという最も大きい問題があります。
どうすっかな。
@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.
っていうエラーが出ますね。
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()
}
}
ビューの外部にソースを持たせ、外のビューでも更新するような仕組みを試してみました。
外部ソースのリスト要素をリセットしたりするのは現実的でないため、@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)")
}
}
}
}
もっと良い方法が見つかりました。
リストの要素に.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
も変わり、リストは要素が変わったと思って再構築します。
これシンプルでいいですね。
編集可能かどうかが変わるとidも変わり、リストは要素が変わったと思って再構築します。
なんかうまく行かないケースもありますね。
なにかな…
List
の側に.id(self.model.items.count)
のように、要素が変わるとidも変わるようなidを付けると、要素数が増えたときに全体が再構築され、.moveDisabled/.deleteDisabled
も適切に設定されますね。
リスト全体の再構築になってしまうので、コストが高くつくケースもありそうです。
List
の側に.id(self.model.items.count)
のように、要素が変わるとidも変わるようなidを付けると、要素数が増えたときに全体が再構築され、.moveDisabled/.deleteDisabled
も適切に設定されますね。
上記では、.onMove
で順序が入れ替わっただけのとき要素数は変わらないので、.deleteDisabled
が位置そのままで残ってしまいます。
.id(self.model.items.hashValue)
のようなものが良いかも知れません。この場合、.items
の中身がHashable
である必要があります。