はじめに
例えば以下のようなコードがあったとします。
from kivy.app import runTouchApp
from kivy.lang import Builder
root = Builder.load_string(r'''
FloatLayout:
RelativeLayout:
size_hint: 0.8, 0.8,
pos_hint: {'center_x': 0.5, 'center_y': 0.5, }
GridLayout:
pos_hint: {'x': 0, 'y': 0, }
cols: 2
Button:
id: button1
text: '1'
Widget:
id: trigger
Widget:
Button:
id: button2
text: '2'
RelativeLayout:
size_hint: 0.8, 0.8,
pos_hint: {'right': 1, 'top': 1, }
id: renderer
''')
runTouchApp(root)
Widget階層が少し複雑なのでWidgetの境界を可視化させた時の結果も貼っておきます。
ここでbutton1
の中央からbutton2
の中央へ線を引く処理をrenderer
に行わせたい時、どのようなコードを書きますか?もし素直に以下のように書いてしまうと
RelativeLayout:
size_hint: 0.8, 0.8,
pos_hint: {'right': 1, 'top': 1, }
id: renderer
canvas:
Line:
points: [*button1.center, *button2.center, ]
となり、失敗してしまいます。
またtrigger
のon_touch_down
イベントが起きた時に、Windowの左下からマウスカーソルの位置へ線を引く処理をrenderer
に行わせたい時、どのようなコードを書きますか?これも素直に以下のように書いてしまうと
#:import Line kivy.graphics.Line
Widget:
id: trigger
on_touch_down: renderer.canvas.add(Line(points=[0, 0, *args[1].pos, ]))
と失敗してしまいます。
これらは全て、得た座標をそのままLine
のpoints
に渡しているのが原因で、正しい結果を得るためにはちゃんと座標変換を行う必要があります。この記事では上の問題を解くと共にKivyの座標の仕組みについての私の理解を述べたいと思います。
ちょっとした逃げ道
その前にですが実は上のコードは、RelativeLayoutを全てFloatLayoutに置き換える事で期待する結果を得られます。 Widget階層の中にRelativeLayout系のWidget
が一つもない時は全ての座標が同じ座標系で表されているため、一切の変換の必要が無くなるのです。 RelativeLayout系のWidget
を全く使わないというのはとても大きな制約ではありますが、座標変換が分からない時の逃げ道として役に立つかもしれません。
語彙
最初に幾つか本記事で使う言葉の定義します。
祖先
以下のようなWidget階層がある時、本記事では
- rootとBはCの祖先
- rootはAの祖先
- rootはBの祖先
と表現します。公式ドキュメントではparent stack
に相当する言葉です。
RelativeLayout系のWidget
RelativeLayoutの子の位置指定は、RelatveLayoutの位置から相対的になります。これはRelativeLayoutが座標の原点を変えているからです。このように座標系を弄るWidgetは他にもScatter
,ScrollView
があり、公式ではそれらを纏めてRelativeLayout type widgets
と言っています。本記事ではこれをRelativeLayout系のWidget
或いはRelative系のWidget
と言うことにします。
Window座標系
Windowの左下を原点とする座標系で、特に座標系を弄るWidgetが無い時の既定の座標系です。公式ではwindow coordinates
。
Local座標系
公式ではlocal coordinates
。
Local座標系はWindow座標系とは違い、基準とするWidgetにより変わります。私は初めLocal座標系を各Widgetの左下を原点とする座標系なのだと思っていました。なにせ名前にlocal
と入っているわけですから。ところが次のコードを見てください。
from kivy.app import runTouchApp
from kivy.lang import Builder
root = Builder.load_string(r'''
FloatLayout:
FloatLayout:
id: hoge
pos: 100, 100
on_touch_down: print(self.to_widget(80, 80))
''')
runTouchApp(root)
to_widget()
はWindow座標をLocal座標に変換するMethodです。この場合hoge
のto_widget()
を呼んでいるので、Window座標(80,80)をhogeのLocal座標に変換しています。私の予想する出力は(-20,-20)
でした。hogeの位置はWindow座標(100,100)で、Window座標(80,80)はそこから(-20,-20)の位置にあるからです。しかし実際は(80,80)
でした。どうやらLocal座標系は私の思っている概念とは違うようです。
色々調べた結果KivyにおけるLocal座標系は以下のように決まるという結論に至りました。
- 自身が
非Relative系のWidget
の時、自身のLocal座標系は親のLocal座標系に等しい。 -
自身が
RelativeLayout
の時、自身の位置がLocal座標系の原点になる。 (話を単純にするためにRelativeLayoutに限定していますが、他のRelative系のWidget
でも考え方は同じです。)
例えば以下のWidget階層がある時
FloatLayout:
id: _1
RelativeLayout:
id: _2
pos: 10, 10
FloatLayout:
id: _3
pos: 1, 1
FloatLayout:
id: _4
pos: 1, 1
RelativeLayout:
id: _5
pos: 30, 30
Widget:
id: _6
pos: 1, 1
各WidgetのLocal座標系の原点は
Widgetのid | Relative系か? | 自身の位置をWindow座標系で表すと | Local座標系の原点の位置をWindow座標系で表すと |
---|---|---|---|
_1 | No | 0, 0 | 0, 0 |
_2 | Yes | 10, 10 | 10, 10 |
_3 | No | 11, 11 | 10, 10 |
_4 | No | 11, 11 | 10, 10 |
_5 | Yes | 40, 40 | 40, 40 |
_6 | No | 41, 41 | 40, 40 |
となります。
親の座標系
親のLocal座標系の事を略して親の座標系
或いは親座標系
と言うことにします。(公式ではparent coordinates
)。何故この言葉を用意したかというと、一番よく使うからです。例えば
- 普段何気なく使っている Widgetの座標を表すプロパティ(pos, x, y, center, center_x, center_y, top, right)は全て持ち主の親のLocal座標系です 。
- また
on_touch_xxx系
のイベントで得られる座標もbind
したWidgetの親のLocal座標系です。 collide_point()
へ渡す引数もMethodの持ち主の親のLocal座標系です。
on_touch_xxx系
で得た座標をそのままcollide_point()
へ渡せるのはこの為でもあります。両方共同じ親のLocal座標系なため、何の座標変換もいらないのです。
冒頭の問題を解く
Local座標系が分かったので冒頭で述べた問題を解いていこうと思います。
再掲
from kivy.app import runTouchApp
from kivy.lang import Builder
root = Builder.load_string(r'''
FloatLayout:
RelativeLayout:
size_hint: 0.8, 0.8,
pos_hint: {'center_x': 0.5, 'center_y': 0.5, }
GridLayout:
pos_hint: {'x': 0, 'y': 0, }
cols: 2
Button:
id: button1
text: '1'
Widget:
id: trigger
Widget:
Button:
id: button2
text: '2'
RelativeLayout:
size_hint: 0.8, 0.8,
pos_hint: {'right': 1, 'top': 1, }
id: renderer
''')
runTouchApp(root)
一つ目の問題
button1
の中央からbutton2
の中央へ線を引く処理をrenderer
に行わせたい時、どのようなコードを書きますか?
ここで考えないといけないのはbutton1.center
とbutton2.center
が何の座標系で表されていて、renderer
のcanvas内では何の座標系を使わないといけないのかです。前者はもう既に分かっています。親座標系の説明で書いたとおり、位置を表すプロパティは親座標系であるため、この場合はそれぞれbutton1の親座標系,button2の親座標系という事になります。(因みにbutton1
とbutton2
は親が同じなのでbutton1の親座標系
とbutton2の親座標系
は同じ物です)。後者なんですが、これは**renderer
のLocal座標系**になります。canvasが何の座標系になっているかは基本的に
-
canvas.before
とcanvas
はLocal座標系 -
canvas.after
は親座標系
で、これに当てはまらない物は私は今の所ScrollView(もちろん派生Classも含む)しか知りません。
to_xxx()系Method
それぞれが何の座標系なのか分かったので、後は変換するだけです。Kivyは変換を行うためのMethodとして以下の4つを提供してくれているので順番に見ていきます。
to_window()
既定では親座標をWindow座標に変換し、引数initial
にFalseを渡した時だけLocal座標をWindow座標に変換します。例えば何かのWidgethoge
がある時、hoge.to_window(0, 0)
は hogeの親座標(0, 0) を Window座標 に変換した結果を返し、hoge.to_window(0, 0, initial=False)
は hogeのLocal座標(0, 0) を Window座標 に変換した結果を返します。 実は公式の説明では
Transform local coordinates to window coordinates. See relativelayout for details on the coordinate systems.
と書いてあり、これは訳すと「Local座標をWindow座標に変換する。詳しくは...」になり、私の説明とは異なります。私はソースを読んだりTestコードを書いたりしてそうとしか思えなかったので上の様に書きましたが、気になる人は自分で確かめるのが良いと思います。
(追記 2019/08/19: 現在はdocは修正され、私の説明と同等の物になっています)
to_widget()
Window座標をLocal座標に変換します。例: hoge.to_widget(0, 0)
は Window座標(0,0) を hogeのLocal座標 に変換した結果を返す。
to_parent()
Local座標を親座標に変換します。例: hoge.to_parent(0, 0)
は hogeのLocal座標(0,0) を hogeの親座標 に変換した結果を返す。
to_local()
親座標をLocal座標に変換します。例: hoge.to_local(0, 0)
は hogeの親座標(0,0) を hogeのLocal座標 に変換した結果を返す。
一つ目の問題の続き
変換系Methodが分かったところで問題の続きをやりたいと思います。やらなければいけないのは button1の親座標(button1.center) と button2の親座標(button2.center) を rendererのLocal座標 に変換することです。直接変換するのは無理なので
- button1の親座標(button1.center) -> Window座標 -> rendererのLocal座標
- button2の親座標(button2.center) -> Window座標 -> rendererのLocal座標
と一度Window座標を経由させます。
RelativeLayout:
size_hint: 0.8, 0.8,
pos_hint: {'right': 1, 'top': 1, }
id: renderer
canvas:
Line:
points:
[
*self.to_widget(*button1.to_window(*button1.center)),
*self.to_widget(*button2.to_window(*button2.center)),
]
二つ目の問題
trigger
のon_touch_down
イベントが起きた時に、Windowの左下からマウスカーソルの位置へ線を引く処理をrenderer
に行わせたい時、どのようなコードを書きますか?
親座標系の説明で書いたとおりon_touch_xxx系のイベントで得られる座標は親座標系です。なのでこの問題では
- Window座標(0, 0) -> rendererのLocal座標
- triggerの親座標(touch.pos) -> Window座標 -> rendererのLocal座標
という変換をする事になります。
#:import Line kivy.graphics.Line
Widget:
id: trigger
on_touch_down:
renderer.canvas.add(Line(points=[
*renderer.to_widget(0, 0),
*renderer.to_widget(*self.to_window(*args[1].pos)),
]))
ちなみにKv言語を用いない場合は以下の様になります。
from kivy.graphics import Line
trigger = root.ids.trigger
renderer = root.ids.renderer
trigger.bind(on_touch_down=(
lambda __, touch: renderer.canvas.add(
Line(points=[
*renderer.to_widget(0, 0),
*renderer.to_widget(*trigger.to_window(*touch.pos)),
])
)
))
canvas.after
を使う場合
上の例ではどちらもcanvas
を使いましたが、代わりにcanvas.after
を使う場合は以下のようになります。
一つ目の問題
RelativeLayout:
size_hint: 0.8, 0.8,
pos_hint: {'right': 1, 'top': 1, }
id: renderer
canvas.after: # 相違点
Line:
points:
[
*self.parent.to_widget(*button1.to_window(*button1.center)), # 相違点
*self.parent.to_widget(*button2.to_window(*button2.center)), # 相違点
]
二つ目の問題
from kivy.graphics import Line
trigger = root.ids.trigger
renderer = root.ids.renderer
trigger.bind(on_touch_down=(
lambda __, touch: renderer.canvas.after.add( # 相違点
Line(points=[
*renderer.parent.to_widget(0, 0), # 相違点
*renderer.parent.to_widget(*trigger.to_window(*touch.pos)), # 相違点
])
)
))
canvas.after
では親座標系を使わないといけないのが紛らわしいです。
最後に
座標系は私が最も理解に時間を要したKivyの概念です。to_window()
のドキュメントの罠さえ無ければ、もっと早く理解できていたと思いますが...。正直最も速く理解する方法はソースコードを読むことだと思います。というのも全部とても短くて分かりやすいコードだからです。以下にLinkを貼るので参考にどうぞ。
kivy/uix/widget.py
全てのWidgetの基本Classであるkivy.uix.widget.Widget
のコードです。非Relative系のWidget
はいっさい上の四つのMethodを上書きしていないので、全ての非Relative系のWidget
の実装でもあります。
kivy/uix/relativelayout.py
Relative系のWidget
はどれもto_parent()
とto_local()
だけを上書きしています。Scatter
もScrollView
もやっている事の本質はRelativeLayout
と同じなので、RelativeLayout
のコードを読むだけで十分だと思います。
kivy/core/window/__init__py
Widget.to_window()
とWidget.to_widget()
は内部で親の同名のMethodを呼び出していますが、その呼び出しの連鎖の行き着く先がこれです。