kivyでドラッグで並べ替えできるリスト

kivyでドラッグで並べ替えできるリストを作った時のメモ

概要

Pythonのマルチプラットフォーム向けGUIライブラリkivyで ドラッグで並べ替えできるリスト(AndroidでよくあるUI、RecycleViewItemTouchHelperで作るんだったかな?) を作ってみた時のメモ。
ちょっと違うけど、大体こんな感じ

ソース

ソースは↓の「開く」をクリックすると表示されます。
ダウンロードしたいときは こちら からどうぞ。
reorderablelist.py

解説

ソースは、表示する項目を制御するクラスReorderableItemと リスト全体を制御するクラスReorderableListで構成される。
(あと、単体実行で動作するテストプログラム)
動作としては、

テストプログラムではReorderableListScrollViewで囲んでスクロール可能にしています。 スクロール不要ならそのまま配置しても可(テストプログラムではwith_scrollFalseに設定)。

ReorderableList

ReorderableListはリスト全体を制御するクラスで、 BoxLayoutを継承したクラス。

追加したプロパティ

追加したプロパティは以下。

名称 内容
item_bg 項目の背景色
item_fg 項目の文字色
list_bg リストの背景色
swipe_distance Swipe検出距離

コンストラクタ

コンストラクタでは、基底クラスの初期化 (orientation"vertical"固定(項目を縦方向に並べるため)、 size_hint_yNone固定(ScrollViewに包むため)) のほか、
背景色の設定と、スクロール可能にするためminimum_height変更時の処理のbindを行っている。

項目追加処理

add_itemが項目追加処理。
パラメータはclsで項目表示に使用するクラス(デフォルトはReorderableItem)と、項目表示クラスの初期化パラメータを受け取る。
これは項目に表示する内容を柔軟に切り替えられるよう、項目表示クラスをReorderableItemを継承したクラスに切り替えられるようにするため。

レイアウト処理

kivyでは、レイアウトの変更命令と実際に変更が行われるまでにディレイがある。
具体的には、add_widget(wid) して直後にwid.posを読み出すと実際に追加されたあとの位置が読み出せない。
これは、add_widget()では処理の予約だけ行い、実際に描画が行われるのは別のところ(mainloop?)で 他のウィジェットのレイアウト結果を五月雨式に反映していくため、 描画結果がposに反映されるまでタイムラグが発生するためと思われる。

そこで、ReorderableListのレイアウトが変更され、処理が終了したタイミングで子ウィジェットに レイアウト完了を通知できるようdo_layout()をオーバライドし、基底クラスの処理が完了した後 各子ウィジェットのdone_parent_layout()をコールしている。

ReorderableItem

ReorderableItemはリストに表示する項目を制御するクラスで、 DragBehaviorクラスとBoxLayoutを継承している。
DragBehaviorクラスはドラッグ処理を実現するクラス。
もう一つの基底クラスをBoxLayoutとすることで、派生クラスのレイアウトを柔軟に設定できるようにしている。

追加したプロパティ

追加したプロパティは以下。

名称 内容
text 項目に表示する文字列
bg_color 項目の背景色
fg_color 項目の文字色
swipe_distance Swipe検出距離

コンストラクタ

コンストラクタでは、基底クラスの初期化 のほか、
項目内部の構築(add_inner_widget())、 背景色の設定と、pos/size変更時にドラッグ範囲を変更する処理のbind、 インスタンス変数の設定を行っている

内部ウィジェットの追加処理(add_inner_widget())

項目の内部の表示を構築する処理。
デフォルトではLabelを1個追加している。
項目の表示内容を変更したい場合は、派生クラスでこの処理をオーバライドし、 必要な内容を追加していく。

ドラッグ開始処理(on_touch_down())

まず、基底クラスのドラッグ開始処理を実行する。
DragBehaviorを継承したクラスの場合、自身がドラッグ対象であればここでTrueが返ってくるので ドラッグ対象としての処理(インスタンス変数の設定)を行い、
自身の描画Canvasを親ウィジェット(self.parent)のcanvasからcanvas.afterに繋ぎかえる。 これはドラッグ中の表示が前面に表示されるようにするためである。

[!NOTE] 通常、add_widget()すると追加されたウィジェットの描画Canvasは親ウィジェットのCanvasに繋がれる。
この親ウィジェットのCanvasに繋がれた描画ウィジェットは後から繋がれたものが前面に表示される。
(縦方向のBoxLayoutの場合下に表示されているウィジェットが前に表示される)
重ならない表示であれば問題ないが、ドラッグを行うとドラッグ中のウィジェットが背面に表示されることがある。
そこで、ドラッグ中のウィジェット他のウィジェットより前面に表示するため、CanvasからCanvas.afterに繋ぎかえ、前面に表示されるようにする。
仕様を読む限り、parent.add_widget(child, index=index, canvas='after')でadd_widget時にcanvas.afterに接続できるようなのだが、 実際にはバグ(仕様制限?)により、indexが0のときのみcanvasパラメータが有効になるらしい。
これを回避するため、parent.add_widget(child, index=index)で通常通りウィジェットを追加した後、

    parent.canvas.remove(child.canvas)
    parent.canvas.after.add(child.canvas)

として描画Canvasを繋ぎかえている。 canvas.afterに繋いだ描画Canvasをcanvasに戻す場合は、

    cur_index = parent.children.index(child)    # canvas.after から canvasへ繋ぎかえるため
    parent.remove_widget(child)                 # 一旦削除して(canvasかcanvas.afterは自動的に判別してくれる) 
    parent.add_widget(child, index=cur_index)   # 再度同じ場所に挿入

と実行する。 remove_widget()は描画Canvasがどこに繋がっていても探して削除してくれるので、 そのまま実行して問題ない。

ドラッグ中処理(on_touch_move())

まず、基底クラスのドラッグ中処理を実行する。
DragBehaviorを継承したクラスの場合、自身がドラッグ対象であればここでTrueが返ってくるので ドラッグ中の処理を行う。
親ウィジェットに繋がっている子ウィジェットをサーチして、自分以外で自分の中心がウィジェットの表示範囲内に入っているウィジェットを探す (child.collide_point(*self.center))
自分の上端が対象ウィジェットの上端を超えたか、自分の下端が対象ウィジェットの下端を超えた場合は自分と対象ウィジェットを入れ替える。
(child.collide_point(*self.center)だけで判断すると、自分より高さが高いウィジェットと入れ替えるときに不都合が起こる)
位置を交換するには、自分を一旦削除(remove_widget())して、対象ウィジェットの位置(i)に繋ぐ(add_widget())。
その後、ドラッグを継続するので、ドラッグ開始時と同様に描画Canvasをcanvas.afterに繋ぎかえる。

ドラッグ終了処理(on_touch_up())

まず、基底クラスのドラッグ終了処理を実行する。
DragBehaviorを継承したクラスの場合、自身がドラッグ対象であればここでTrueが返ってくるので ドラッグ終了の処理を行う。
ドラッグ開始/ドラッグ中処理でcanvas.afterに繋ぎかえた描画Canvasをcanvasに戻すために一旦remove_widget()してから add_widget()する(これによりドラッグにより表示が移動していた対象ウィジェットが通常位置に戻る)。
その後、対象ウィジェットの表示位置がドラッグ開始時と変わっていなく、ドラッグした距離が設定値を超えている場合は スワイプ処理(do_swipe_right()またはdo_swipe_left())を実行する。

右へswipeしたときの処理(do_swipe_right())

現状、対象ウィジェットを削除する。
処理を変更する場合は派生クラスでオーバライドする。

左へswipeしたときの処理(do_swipe_right())

現状、デバッグ用にログ出力のみ。
処理を変更する場合は派生クラスでオーバライドする。

親ウィジェットのレイアウト完了時処理(done_parent_layout())

親ウィジェットのレイアウト完了時に子ウィジェットすべてのこのメソッドがコールされる。
ドラッグ中であれば必要な処理(現在位置の記憶とドラッグ中位置への移動)を行う。

サンプルプルグラム

ソースは↓の「開く」をクリックすると表示されます。
ダウンロードしたいときは こちら からどうぞ。
サンプルプルグラム