はじめに
お疲れ様でございます。ハム二郎です![]()
前回に引き続き、短歌対戦アプリの開発記録をまとめます。
特に今回は、実際に作成しているコードの中身についてまとめ、学習内容を整理します。なお、記事が1,000行を超え膨大な量となるため、2回に分けて記事を作成します。
今回は特に、UIと基礎設計を中心に前半部分としてまとめたいと思います。
この記事は「独学 → 試行錯誤 → 構造理解」のプロセスを記録したものですので、誤りがあればぜひコメントでご指摘いただけますと幸いです。
目次
コード内容
まとめ
進捗と注意点
進捗
現在の進捗は以下の画像の通りです。
後述しますが、目次の「コード内容」に記載している内容までコードとして記述しています。
注意点
注意点として、現在のコードを実行すると途中の動作で止まります。
発生した問題と原因分析に分けて報告します。
発生した問題:ドラッグ&スナップが成立しない
ドラッグ&スナップのコードを入れていますが成立せず、下記のGIF画像のように具体的には以下の問題が発生しています。
- カードをクリックすると即座に移動してしまう
- 連続クリックで無関係なスロットへワープする
- 最終的には操作不能になる
原因分析
ドラッグ&スナップができない理由について、手札欄にて、下記の通りCardではなくButttonを使用していることが原因の1つだと考えました。
card = Button(text=f"{text}\n({length})",
background_color=self.colors[length],
pos=(x, 100), size_hint=(None, None), size=(100, 120))
しかし、Cardに変更するとAttributeError: 'TankaScreen' object has no attribute 'slots'がエラーコードとして表示されました。
エラーコードをよく確認すると、以下のカードクラスのコードに原因がありました。
# スナップ動作(近い枠に吸着)
for slot in self.parent.parent.slots:
if slot.collide_point(*touch.pos):
self.snap_to_slot(slot)
return True
ここをどのようにすればよいか分からずChatGPTの助けを借りたところ、以下の通り構造が崩れていることが判明しました。
| 階層構造 |
|---|
| TankaScreen └ FloatLayout (self.layout) ├ BoxLayout (slot_layout) │ ├ SlotColumn │ │ ├ Slot │ │ └ ... └ Card (手札) |
現在の構造では Card から見える範囲は以下の通りです。
-
self.parent→FloatLayout -
self.parent.parent→TankaScreen
つまり、Card から SlotColum や Slot は見えず、self.parent.parent.slots は存在していないことでスナップ不可となっていたのです。
得られた気づき
この問題を通して分かったことをまとめます。
- UI階層に強く依存した参照は脆い
- 子Widget が兄弟関係の内部構造にアクセスする設計は破綻しやすい
- ドラッグ&スナップは そもそも TankaScreen が管理者になるべき
つまり、構造的には下記の通りにするべきでした。
【 悪い例 】
Card → parent → parent → slot にアクセスしに行く
(子から親へ依存しすぎ)
【 良い例 】
TankaScreen = 管理者
↓
TankaScreen が 「全スロットの一覧」を持つ
↓
Card は TankaScreen に登録される
ドラッグ&スナップの不具合は「イベントが届かない」問題のため、原因はレイアウト階層と参照関係にあることが判明し、小手先の修正では膨大な時間がかかるため、今後はリファクタリングによる設計の再構築が必要であることに気づきました。
また、ドラッグ&スナップ以外に UI 配置やスコアロジックなどにも問題がありますが、この点は次回の記事にて実際のコードを整理する際に分析したいと思います。
以下からはコード内容の進捗状況をまとめます。
導入部分
import random
from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.label import Label
from kivy.uix.button import Button
from kivy.uix.widget import Widget
from kivy.uix.screenmanager import ScreenManager, Screen
from kivy.animation import Animation
from kivy.properties import StringProperty, ListProperty
from kivy.graphics import Color, Rectangle, Ellipse, RoundedRectangle
from kivy.core.text import LabelBase
導入部分はimport処理を行うと学習しましたので、現在までに導入しているモジュール等を上記の通り記述しています。
フォント
LabelBase.register(name="Roboto", fn_regular="C:/Windows/Fonts/HGRME.TTC")
初期は英語のみの状態であり、□で文字化けしているような部分も多くありましたので、以下の通り日本語フォントに統一しています。
追記
https://github.com/momijiame/japanize-kivy
にて、kivyが日本語表示できるようにするライブラリがあったので、今後はこちらも使用してみたいと思います。
アプリクラス
class TankaApp(App):
def build(self):
sm = ScreenManager()
sm.add_widget(TankaScreen(name="tanka"))
return sm
「App」を継承してアプリ全体を定義し、核を作っています。
前回の記事である程度学習済の箇所なので、内容については下記に簡単にまとめます。
| 行 | 内容 | 意味 |
|---|---|---|
class TankaApp(App): |
アプリ全体を定義 | 「App」を継承して短歌アプリを作る |
def build(self): |
起動時に呼ばれる関数 | 最初に表示する画面を決める |
sm = ScreenManager() |
画面管理者を作成 | 複数画面をまとめる枠 |
sm.add_widget(TankaScreen(name="tanka")) |
短歌画面を登録 | 最初の画面を追加 |
return sm |
画面を返す | これが実際に表示される |
ScreenManager は画面を「名前」で識別するので、TankaScreen をオブジェクト化する際、「Tanka」という名前を与えています。
なお、Kivyの文脈では慣習的に sm = “screen manager” と略して使います。
カードクラス
プロパティ定義(Kivyの反応型変数)
class Card(Label):
card_color = ListProperty([1, 1, 1, 1]) # RGBA
card_text = StringProperty("")
「Label」(文字表示用の基本ウィジェット)を継承及び拡張し、「文字+色+ドラッグ移動可能」な札を作ります。
Property について下記の記事が非常に分かりやすかったです。
https://qiita.com/gotta_dive_into_python/items/b7d8d19dd49599182c9d
この記事では、property について「あらかじめ関数を結び付けておくと属性が変化した時にその関数を呼び出してくれる」変数と表現されています。つまり、「リアクティブ変数(変化を自動検知する変数)」(監視したいプロパティの名前=プロパティが変化した時に呼びたい関数) だということです。
上記では、Cardインスタンス生成時にself.card_color に初期値 [1,1,1,1] を設定し、self.card_color に新しい値が代入されるたびに on_card_color(self, instance, value) イベントが呼ばれる形となっています。
| プロパティ | 型 | 意味 |
|---|---|---|
card_color |
ListProperty | 札の色(RGBA形式:R,G,B,透明度) |
card_text |
StringProperty | 札に表示する文字(「春」「風」など) |
KivyのUI更新システム(Canvas描画やkvバインド)は「Propertyクラスを継承したオブジェクト」を監視しており、変更を検知して自動的にUIを更新するため、普通の変数では自動更新できません。
初期化後の属性設定
def __init__(self, text, color, **kwargs):
super().__init__(**kwargs)
self.text = text
self.card_color = color
self.size_hint = (None, None)
self.size = (120, 60)
self.font_size = 22
self.bold = True
self.halign = "center"
self.valign = "middle"
上記コードの時点では Card(...) をまだ呼び出していないので、self は実際の Card オブジェクトを指していません。
ここでの self は「将来 Card(...) が呼ばれたとき(=Cardがオブジェクト化されたとき)に、その新しく作られるオブジェクト自身を指すようになるための引数の名前」としてあらかじめ定義しています。
呼び出し式 Card(...) の裏側では、実際には
type.__call__が『__new__→__init__』をこの順で自動的に呼ぶのが Python の仕様。
つまり、コンストラクタ(__init__)は、__new__ で作られたオブジェクトを受け取り、そのオブジェクトに初期設定を行う「受け皿」(=セットアップ関数)として機能します。
| 属性 | 意味 |
|---|---|
text |
札に表示する文字(例:「風」) |
card_color |
札の色(RGB値) |
size_hint = (None, None) |
自動サイズ調整を無効化(固定サイズにする) |
size = (120, 60) |
札の大きさ |
font_size = 22 |
文字の大きさ |
bold = True |
太字 |
halign / valign |
水平・垂直方向の中央寄せ |
背景の矩形(カードの見た目)
self.bind(pos=self.update_canvas, size=self.update_canvas)
with self.canvas.before:
Color(*self.card_color)
self.rect = Rectangle(pos=self.pos, size=self.size)
self.dragging = False
self.original_pos = (0, 0)
『self.bind(pos=self.update_canvas, size=self.update_canvas)』
bind …「プロパティの変化を検出し関数を呼ぶ」Kivyの機能。
ここでは、pos(位置)や size(大きさ)が変わった時に、プロパティの値が変わることをKivyが自動で検出し update_canvas() が呼ばれ、背景の四角形 ( self.rect ) の位置とサイズを描き直すようにしています。
つまり、カードを動かしたら自動で見た目(描画)も更新されるようにしています。
『with self.canvas.before:』
ここではこのブロックの中で「Widgetを描画する前」に表示するグラフィックを定義します。
Kivyのcanvasには下記の3つの描画層があり、
| 描画層 | 内容 |
|---|---|
| canvas.before | 背景(Widgetの下に描く) |
| canvas | メインの描画 |
| canvas.after | 前景(Widgetの上に描く) |
ここではカードの背景色や枠(四角形)を、ラベルや文字より下に描くために canvas.before を使っています。
『Color(*self.card_color)』
以降の描画で使う「色」を設定しています。
「
*」 (アンパック演算子)の意味
リストを分解して渡すというPython構文。
例:Color(*[1, 0, 0, 1]) → Color(1, 0, 0, 1)(赤)
『self.rect = Rectangle(pos=self.pos, size=self.size)』
実際にカードの「長方形」を描いています。具体的に、カードの見た目としての四角形(Rectangle)を生成し、記憶しておきます。
-
pos=self.pos
このWidgetの左下座標に合わせて描く -
size=self.size
このWidgetと同じサイズで描く
『self.dragging = False』
今このカードが「ドラッグ中かどうか」を管理するフラグ(状態変数)です。
-
Trueならドラッグ中 -
Falseなら静止中
後で on_touch_down や on_touch_move の中で変更します:
『self.original_pos = (0, 0)』
ドラッグ前のカード位置を覚えておくための変数です。
ドラッグ開始
def on_touch_down(self, touch):
if self.collide_point(*touch.pos):
self.dragging = True
self.original_pos = self.pos
return True
return super().on_touch_down(touch)
『def on_touch_down(self, touch):』
「画面が押された瞬間にKivyが呼び出す処理」を定義しており、その際にKivyが「どのWidgetが押されたか(self)」と「タッチの詳細情報(touch)」を引数として自動で渡してくれるという内容です。
『if self.collide_point(*touch.pos):』
この1行で「タッチされた座標が、このカード(Widget)の範囲内かどうか」を判定しています。
self.collide_point() はKivyのWidgetが持つメソッドであり、引数に (x, y) を与えるとその座標がWidget(カード)の矩形の中にあるかを True / False で返します。(collide_pointを直訳すると「衝突位置」)
touch.pos は タプル(=2つの値をまとめたもの)ですが、Kivyの collide_point() は、上記のように2つの引数を取る関数であるため、タプルのまま渡すのではなく、中身を展開して渡す必要があります。
そのため、 *(アンパック演算子)を付けています。
『self.dragging = True』
このカードが「今タッチされ、ドラッグ開始したことを記録」しています。これを別のメソッド(例: on_touch_move)で使うことで、ドラッグ中だけカードを動かすように制御できます。
『self.original_pos = self.pos』
カードの「元の位置(タッチ前の座標)」を基準点として保存しておきます。
『return True』
「このカードがタッチイベントを受け取って完了した」と宣言します。
Kivyにおいて、イベントは階層的に伝わります(=イベント伝搬)。`return True` にて完了宣言をし、同じイベントを親Widgetへ渡さないようにすることで、重複処理を避けます。
『return super().on_touch_down(touch)』
この行はインデントによりifブロックの外に書かれているため、「ifがFalseの場合に実行」 されます。
つまり、もしタッチがカードの範囲外だった場合(ifがFalseの場合)、このイベントを親Widgetに渡すという意味です。なお、直訳すると 「親クラスの on_touch_down() を呼び出し、その結果を返す」 となります。
super() は親クラス(ここでは Widget)を呼び出します。Widget クラスも on_touch_down() を持っているため、それが他の子要素にイベントを伝える役目を担います。
ドラッグ中(移動)
def on_touch_move(self, touch):
if self.dragging:
self.center_x = touch.x
self.center_y = touch.y
return True
return super().on_touch_move(touch)
『def on_touch_move(self, touch):』
「タッチ位置が動いた際にKivyが呼び出す処理」を定義しており、それ以降は前述の通りです。
『if self.dragging:』
「今、このカードがドラッグ中かどうか」を判定しています。
on_touch_down() の中で「カードが押されたとき」に True に設定されていますので、押された場合はself.dragging = Trueとなります。
『self.center_x = touch.x
self.center_y = touch.y』
タッチした位置(指の位置)に合わせて、カードの中心座標を動かしています。
pos はウィジェットの左下の座標
center_x / center_y は中心の座標
カードをドラッグするとき、多くの場合「中心を指で掴むように」動かしたいので、このように書くと自然な動きになります。
『return True
return super().on_touch_move(touch)』
前述の通りのため省略します。
ドラッグ終了(ドロップ処理)
def on_touch_up(self, touch):
if self.dragging:
self.dragging = False
# スナップ動作(近い枠に吸着)
for slot in self.parent.parent.slots:
if slot.collide_point(*touch.pos):
self.snap_to_slot(slot)
return True
# 戻す
Animation(x=self.original_pos[0], y=self.original_pos[1], d=0.3, t='out_quad').start(self)
return True
return super().on_touch_up(touch)
『def on_touch_up(self, touch):』
「タッチを離した瞬間にKivyが呼び出す処理」を定義しており、それ以降は前述の通りです。
『if self.dragging:』
前述の通りのため省略します。
『self.dragging = False』
ドラッグ状態を解除し、この一行でon_touch_move がもう反応しなくなります。
なお、on_touch_up() が呼ばれるのは、
「もう指を離したあと」=ドラッグ操作が終わった瞬間なので成立しています。
『for slot in self.parent.parent.slots:』
self(カード)の親の親が持つ slots というコレクション(通常は list)から先頭→末尾の順で1つずつ取り出し、都度 slot という変数名で使えるようにします。
要するに、「TankaScreen内にあるすべてのSlotを1つずつ取り出して確認する」という内容です。
for-in 構文(=自動イテレータ機構)
Pythonでは、for ... in ...: の右側に書けるものはすべて「反復可能(iterable)」 でなければいけません。反復可能(iterable)」とは、__iter__() メソッドを持っているオブジェクトのことです。
上記コードは、内部的に下記の通り変換されて実行されています。
# ① iterable から イテレータを作る
_iter = iter(self.parent.parent.slots)
# ② 無限ループ開始
while True:
try:
# ③ イテレータから次の要素を1つ取り出す
slot = next(_iter)
# ④ 要素がもう無いとき
except StopIteration:
# ⑤ ループを終了
break
# ⑥各スロットでの処理
if slot.collide_point(*touch.pos):
self.snap_to_slot(slot)
return True
なお、イテレータは「次の要素を覚えておくオブジェクト」です。
つまり、for-in は、Pythonが裏で 「iter() でイテレータを作り、next() で順に取り出し、なくなったら StopIteration で止める」という処理を自動 でやってくれている構文です。
『if slot.collide_point(*touch.pos):』
「カードを離した場所が、このslotの範囲に重なっているか」を判定しています。詳細は前述の通りです。
『self.snap_to_slot(slot)』
「カードを、このslotの位置に吸着させる(スナップする)」という意味です。snap_to_slot() は別途定義されている自作メソッドです。
『return True』
前述の通りです。
『Animation(x=self.original_pos[0], y=self.original_pos[1], d=0.3, t='out_quad').start(self)』
Kivyの Animation クラスは、Widgetのプロパティ(例:位置・サイズ・透明度など)を時間をかけて自動的に変化させる仕組みです。
ここでは、元の位置(self.original_pos)まで、0.3秒かけてスムーズに戻すアニメーションを開始します。
d=0.3 … duration:アニメーションの時間(0.3秒)
t='out_quad' の t は transition(トランジション)関数の略です。つまり、アニメーションの動き方(速度カーブ) を指定します。
t に文字列で指定すると、Kivyがあらかじめ用意しているカーブを使って動きを作ります。
| 値 | 動き方 | 説明 |
|---|---|---|
'linear' |
等速 | 一定の速度 |
'in_quad' |
加速 | ゆっくり→速く |
'out_quad' |
減速 | 速く→ゆっくり(自然) |
'in_out_quad' |
加速→減速 | 両端がゆっくり |
'out_back' |
オーバーシュート | ちょっと行きすぎて戻る |
'out_bounce' |
バウンド | 跳ねるように止まる |
.start() は、「このアニメーションを開始する」という命令で、引数にどのWidgetを動かすかを指定します。
要するに、Animation(x=100, y=200, d=0.3, t='out_quad').start(self) は、「このWidget(self)を、x=100・y=200まで、0.3秒かけて“out_quad”の動きで移動させる(アニメーションの開始)」という意味です。
『return True
return super().on_touch_move(touch)』
前述の通りのため省略します。
スナップ処理(吸着)
def snap_to_slot(self, slot):
Animation(x=slot.x, y=slot.y, d=0.2, t='out_cubic').start(self)
slot.add_card(self)
「カードを指定されたスロットの座標まで0.2秒かけて滑らかに(out_cubicで)移動させ、そのスロットに配置されたことを記録」するという意味です。
Animation() は上記の通りです。
『def snap_to_slot(self, slot):』
「このカードを、指定されたスロット(slot)にスナップさせる処理を定義する関数」です。この関数は上記の on_touch_up() で呼ばれます
『slot.add_card(self)』
.add_card(self) はSlot クラスが持つ「カードを受け入れるメソッド」であり、「slot(スロット) が、self(自分自身=このカード) を add_card(カードとして追加する)」という意味です。
「このスロットに、このカードが正式に置かれた」という事実を内部データとして記録します。
背景矩形を更新
def update_canvas(self, *args):
self.rect.pos = self.pos
self.rect.size = self.size
『def update_canvas(self, *args):』
「キャンバス(描画領域)を更新する関数を定義する」という意味です。
![]()
*argsが使用される理由
*argsが使われているのは、Kivyのbind()が「位置引数(pos や size の値)を渡す」ためであり、**kwargs(キーワード引数)では受け取れないからです。
*args… 複数の位置引数をまとめて受け取るタプル。
名前は自由だが、慣習的に「args」を使用している。
*がないと、Pythonは「引数はいくつ必要か」を正確に一致させようとするため、先頭に付けている。
『self.rect.pos = self.pos
self.rect.size = self.size』
「Widgetの動きに合わせて描画も追従させる」ようにしています。
Kivyでは、with self.canvas: の中で作られた図形(例:Rectangle)は手動で位置を更新しないと動かないのでここで入れています。
rect(レクト)は rectangle(レクタングル)=長方形 の略で、Kivyにおいては 「画面上に描かれる四角形オブジェクト」 を指します。
Rectangle … Kivyが提供する図形描画クラス。画面上に長方形(矩形)を描くためのオブジェクト。
スロットクラス
class Slot(Widget):
def __init__(self, color, label, **kwargs):
super().__init__(**kwargs)
self.size_hint = (None, None)
self.size = (130, 70)
self.label = label
self.card = None
with self.canvas.before:
Color(*color)
self.rect = Rectangle(pos=self.pos, size=self.size)
self.bind(pos=self.update_rect, size=self.update_rect)
Kivy の基本UIパーツの親クラスである Widget クラスをもとに新しいクラス Slot を作り、Slot オブジェクトが作られたときに初期設定を行うよう設定しています。
その後、下記の属性を追加しています。
『self.size_hint = (None, None)』
自動サイズ調整を無効化しています。
Kivyでは size_hint=(1,1) がデフォルトで、親レイアウトの比率で伸縮します。(None,None) にすると、size で固定サイズを指定できるようになります。
hint… 親レイアウトに「どのくらいのサイズがいいか」を伝える比率指定。
『self.size = (130, 70)』
スロットの大きさを固定(幅130px、高さ70px)しています。
『self.label = label』
このSlot自身(self)に、label という名前の属性を作り、呼び出し側から引数で受け取った label の値を代入することで「Slot(スロット)」というオブジェクト(インスタンス変数)に個別の情報(属性)を追加しています。
「Python では右の値が左に代入される」 という絶対ルールがあるため、記述位置が変わると意味は根本から変わります。
上記を例に、
length = self.lengthにしてしまうと、インスタンス変数の値をローカル変数にコピーするだけになります。__init__は呼び出し側から受け取った値を属性として追加する役割のため、ここではself.label = labelにする必要があります。
『self.card = None』
このSlot自身(self)の中に card という属性を作り、初期状態を None(=何もない)にしています。後でカードを置くときに self.card = card とし、そのカードを記録するという状態の切り替えのために存在します。
『with self.canvas.before:
Color(*color)
self.rect = Rectangle(pos=self.pos, size=self.size)』
with構文により、self.canvas.before を「今の描画対象キャンバス」として有効化できます。
-
self.canvas=Slotの描画レイヤー(絵を描く場所) -
.beforeは「他の要素より前(背景側)」に描画する指
以上により、このコードは「このウィジェットの背景レイヤーに、図形や色を描く命令を書きます」という宣言になります。
Kivy の Color はColor(r, g, b, a=1)と定義されていますので、引数は「4つの数値(r,g,b,a)」 です。
しかし、手元にあるのは「タプル1つ」ですので、* で展開しそれぞれの引数に対応付けを行います。
つまり、Color(1, 0, 0, 1)によりこれから描く図形は赤く塗るよう指定しています。
self.rect = Rectangle(pos=self.pos, size=self.size) は Rectangle(図形)を作るとき、Widget(self)の位置をそのまま Rectangle の位置として使うという内容です。
pos=self.pos は Slot の現在位置(左下の座標)
size=self.size は Slotの大きさ(幅と高さ)
前述の通り右辺を左辺に代入することに鑑み、
Rectangleの初期位置をWidget(self)と同位置に指定するため、self...が右辺に来ています。
self.rect = ...と代入している理由は、後で位置が変わったときに再描画するためです。
『self.bind(pos=self.update_rect, size=self.update_rect)』
「プロパティの変化を監視する関数」である bind により、このウィジェットの pos(位置)や size(サイズ)が変化したら、update_rect という関数を自動的に呼び出し、合わせて背景の四角形(rect)も動かすという内容です。
def update_rect(self, *args):
self.rect.pos = self.pos
self.rect.size = self.size
def add_card(self, card):
self.card = card
card.center = self.center
『def update_rect(self, *args):』
このスロット自身(self)の矩形(rect)を更新するための関数を定義するという内容で、呼び出し元(bindなど)から余分な引数が来ても全部まとめて受け取ります。
self.rect.pos = self.pos 及び self.rect.size = self.size についても前回と同様に、「Widgetの動きに合わせて描画も追従させる」ようにしています。
Kivyでは、with self.canvas: の中で作られた図形(例:Rectangle)は手動で位置を更新しないと動かないのです。
『def add_card(self, card):
card.center = self.center』
このスロット自身(self)にカードを登録し、画面上でもスロットの中心にカードを配置するための関数定義です。
self.card = card 及び card.center = self.center についても、スロットの中に置かれたカードを記録し、スロットの中央をカードの中央座標に合わせています。
さいごに
ここまで読んでいただき、誠にありがとうございました![]()
今回の前半記事で得た成果は以下の通りです。
- UI 階層構造を大まかに理解できた
- イベント伝搬の理解が体験を通じて得られた
- ドラッグ&スナップが破綻した原因を特定できた
- 構造再設計が必要だと判断できた
次回では後半部分について学習記録をまとめ、課題点や今後の予定などについて記載したいと思います。
前半である今回はここまでとさせていただきます。
参考文献
Qiita
-
「PythonのGUIライブラリKivyを使う」@ tapitapi 様
https://qiita.com/tapitapi/items/a4a4e3a7164afd922115 -
「Pythonで作るGUIアプリ with kivy 基礎編」@ phorizon20 様
https://qiita.com/phorizon20/items/21edc30b2d5afaa606c8 -
「Kivyの基本部品 Property」@ gotta_dive_into_python 様
https://qiita.com/gotta_dive_into_python/items/b7d8d19dd49599182c9d
Github
- 「japanize-kivy」@ momijiame 様
https://github.com/momijiame/japanize-kivy

