14
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【Swift】配列のmove関数を調査

Last updated at Posted at 2020-01-30

Swiftではこれまで配列要素に対して位置の移動をさせる簡潔なメソッドがなく、こちらのStackoverflowの記事のように、配列から取り除いて、取り除いた要素を任意の箇所に挿入するという手間をかけていました。

temp.swift
let element = arr.remove(at: 3)
arr.insert(element, at: 2) 

しかし、iOS13のランタイム依存関数ではありますが、今では以下の関数を利用することでシンプルに配列要素の移動を行うことが出来ます。

move.swift
mutating func move(fromOffsets source: IndexSet, toOffset destination: Int)

この関数MutableCollectionプロトコルに宣言されています。

ただ、ドキュメントを見ても記事投稿時点では解説がまだ何も書いていないので、少しサンプルコードとテストコードを利用しながら挙動を探っていこうと思います。

用語

オフセット: 先頭要素からの距離

挙動調査

気になるのは、fromOffsetsIndexSetで渡して、toOffsetIntで渡しているところです。

IndexSetは整数で値被りのないコレクションなのですが、複数インデックスを渡して、対象のオフセットに移動させる...。という操作のイメージが今ひとつ湧かないので、その辺りも明らかにしていきます。

サンプル1.ひとつだけIndexを渡して移動させる

文字列の"a"から"e"までを配列にして、中身の要素を動かしながら検証していきます。今回の検証では"b"を動かします。

tests.swift

    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のonMoveonDeleteに対応するように作られたようなので、実際にListの移動でどのように出力されるか検証します。

コードはこちらからお借りしました。よければお手元の環境でお試しください。
https://www.hackingwithswift.com/quick-start/swiftui/how-to-let-users-move-rows-in-a-list

ContentView.swift
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の位置のまま離す

スクリーンショット 2020-01-30 16.40.58.png
source:1
destination:1

bを先頭にする

スクリーンショット 2020-01-30 16.41.05.png
source:1
destination:0

bをcの次に持ってくる

スクリーンショット 2020-01-30 16.40.33.png
source:1
destination:3

bをdの次にもってくる

スクリーンショット 2020-01-30 16.43.21.png
source:1
destination:4

bを最後に持ってくる

スクリーンショット 2020-01-30 16.44.30.png
source:1
destination:5

実験結果

自分では

  1. fromOffsetsで指定した要素をremove
  2. fromOffsetsで指定した要素をtoOffset位置にinsert

という解釈で進めていたのですが、実際の挙動のイメージとしては、自分の想定と実行順が逆で以下のような形です。

  1. fromOffsetsで指定した要素をtoOffset位置にinsert
  2. fromOffsetsで指定した要素をremoveする。

※あくまでイメージなので、実際の内部挙動と異なる場合があります。

例えば、"c"の次に"b"を持ってきたい場合、以下のようになります。

変更前配列.swift
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として渡す引数の法則が分かったので、このサンプルはそれを前提に進めていきます。

moveItems.swift

    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風ドキュメントを書いて終わろうと思います。

Doc.swift
/// 指定した要素を、指定した場所に移動させる。
/// - 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) 
14
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?