Swiftではこれまで配列要素に対して位置の移動をさせる簡潔なメソッドがなく、こちらのStackoverflowの記事のように、配列から取り除いて、取り除いた要素を任意の箇所に挿入するという手間をかけていました。
let element = arr.remove(at: 3)
arr.insert(element, at: 2)
しかし、iOS13のランタイム依存関数ではありますが、今では以下の関数を利用することでシンプルに配列要素の移動を行うことが出来ます。
mutating func move(fromOffsets source: IndexSet, toOffset destination: Int)
この関数はMutableCollectionプロトコルに宣言されています。
ただ、ドキュメントを見ても記事投稿時点では解説がまだ何も書いていないので、少しサンプルコードとテストコードを利用しながら挙動を探っていこうと思います。
用語
オフセット: 先頭要素からの距離
挙動調査
気になるのは、fromOffsets
をIndexSet
で渡して、toOffset
をInt
で渡しているところです。
IndexSetは整数で値被りのないコレクションなのですが、複数インデックスを渡して、対象のオフセットに移動させる...。という操作のイメージが今ひとつ湧かないので、その辺りも明らかにしていきます。
サンプル1.ひとつだけIndexを渡して移動させる
文字列の"a"から"e"までを配列にして、中身の要素を動かしながら検証していきます。今回の検証では"b"を動かします。
func testMoveItemToOffset0() {
var items = ["a", "b", "c", "d", "e"]
items.move(fromOffsets: IndexSet([1]), toOffset: 0)
XCTAssertEqual(items.joined(), "bacde")
}
func testMoveItemToOffset1() {
var items = ["a", "b", "c", "d", "e"]
items.move(fromOffsets: IndexSet([1]), toOffset: 1)
XCTAssertEqual(items.joined(), "abcde")
}
func testMoveItemToOffset2() {
var items = ["a", "b", "c", "d", "e"]
items.move(fromOffsets: IndexSet([1]), toOffset: 2)
// expected
// XCTAssertEqual(items.joined(), "abcde")
// actual
XCTAssertEqual(items.joined(), "abcde")
}
func testMoveItemToOffset3() {
var items = ["a", "b", "c", "d", "e"]
items.move(fromOffsets: IndexSet([1]), toOffset: 3)
// expected
// XCTAssertEqual(items.joined(), "acdbe")
// actual
XCTAssertEqual(items.joined(), "acbde")
}
func testMoveItemToOffset4() {
var items = ["a", "b", "c", "d", "e"]
items.move(fromOffsets: IndexSet([1]), toOffset: 4)
// expected
// XCTAssertEqual(items.joined(), "acdeb")
// actual
XCTAssertEqual(items.joined(), "acdbe")
}
自分が期待した挙動をしません。なぜだろう
SwiftUIでどう使われるかを見る
このmoveメソッドは、SwiftUIのonMove
とonDelete
に対応するように作られたようなので、実際にList
の移動でどのように出力されるか検証します。
コードはこちらからお借りしました。よければお手元の環境でお試しください。
https://www.hackingwithswift.com/quick-start/swiftui/how-to-let-users-move-rows-in-a-list
struct ContentView: View {
@State private var users = ["a", "b", "c", "d", "e"]
var body: some View {
NavigationView {
List {
ForEach(users, id: \.self) { user in
Text(user)
}
.onMove(perform: move)
}
.navigationBarItems(trailing: EditButton())
}
}
func move(from source: IndexSet, to destination: Int) {
print("source:\(source.first!)")
print("destination:\(destination)")
users.move(fromOffsets: source, toOffset: destination)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
bをbの位置のまま離す
source:1
destination:1
bを先頭にする
source:1
destination:0
bをcの次に持ってくる
source:1
destination:3
bをdの次にもってくる
source:1
destination:4
bを最後に持ってくる
source:1
destination:5
実験結果
自分では
-
fromOffsets
で指定した要素をremove
-
fromOffsets
で指定した要素をtoOffset
位置にinsert
という解釈で進めていたのですが、実際の挙動のイメージとしては、自分の想定と実行順が逆で以下のような形です。
-
fromOffsets
で指定した要素をtoOffset
位置にinsert -
fromOffsets
で指定した要素をremove
する。
※あくまでイメージなので、実際の内部挙動と異なる場合があります。
例えば、"c"の次に"b"を持ってきたい場合、以下のようになります。
var items = ["a", "b", "c", "d", "e"]
items.move(fromOffsets: IndexSet([1]), toOffset: 3)
// a is at 0
// b is at 1
// c is at 2
// d is at 3 ← この位置のindexを指定
// e is at 4
// result: items[2] is "b"
この違いについては、自分の事前イメージが誤っており、正しい挙動の方が正しいAPIデザインであると感じます。
理由としては、指定した要素を取り除いた後の配列に対しての追加先index指定だと同じ関数の引数なのに、後者は副作用が出た前提でデザインされている
となるからです。
正しい挙動では、両方の引数が副作用が出ていない状態の配列に対する指定になるため、きれいに揃っています。
サンプル2. 複数のIndexを渡して移動させる
一つ前のサンプルでtoOffset
として渡す引数の法則が分かったので、このサンプルはそれを前提に進めていきます。
func testMoveItemsToOffset3() {
var items = ["a", "b", "c", "d", "e"]
items.move(fromOffsets: IndexSet([0, 1]), toOffset: 3)
XCTAssertEqual(items.joined(), "cabde")
}
このテストケースではIndexSet([0, 1])
を渡しています。なので、"c"の次に"a", "b"が挿入され、cabde
という文字列が得られることを期待しましたが、期待通りの結果を得ることが出来ました。
まとめ
いくつかコードを書いて実験しながら調査を進めることで、MutableCollectionのmoveメソッドの挙動について理解を深めることが出来ました。ざっくりまとめるために、Apple風ドキュメントを書いて終わろうと思います。
/// 指定した要素を、指定した場所に移動させる。
/// - Parameters:
/// - source: 移動させる要素のオフセットのIndexSet
/// - destination: 移動先のオフセット。要素移動を適応していないインデックスを指定する
///
/// var items = ["Alice", "Bob", "Chales", "David", "Elen"]
/// items.move(fromOffsets: IndexSet([0, 1]), toOffset: 3)
/// items[0]: Chales
/// items[1]: Alice
/// items[2]: Bob
/// items[3]: David
/// items[4]: Elen
mutating func move(fromOffsets source: IndexSet, toOffset destination: Int)