今回はRVを使って以下のようなchat viewerを作ってみます。

前回作ったものとは違ってview widgetの大きさはまちまちです(文章量に合わせてview widgetの大きさを変えないといけない)。実はこのような物を普通に作るとview widgetの大きさがズレてしまう事があって困っていたのですが、最近その対策に気付いたので今回取り上げました。
それでは実際に作っていきます。
viewclassを実装
ここら辺は文字列を自動で折り返したい時のよくあるやり方になっています。
<ChatMsg@Label>:
# size_hint: 1, None
height: self.texture_size[1]
text_size: self.width, None
canvas.before:
Color:
rgba: 1, 1, 1, .4
Line:
points: [self.x, self.y, self.right, self.y, ]
-
size_hintの部分がcommentとなっているのはどうせRV側でdefault_size_hintを指定しないといけないため冗長だからです。 - canvasの部分は上の画像で見えている横線です。
RV側を実装
<ChatViewer>:
orientation: 'vertical'
RecycleView:
id: rv
viewclass: 'ChatMsg'
always_overscroll: False
RecycleBoxLayout:
size_hint: 1, None
height: self.minimum_height
default_size_hint: 1, None
spacing: 5
padding: 5
orientation: 'vertical'
import itertools
from collections import defaultdict
from kivy.uix.boxlayout import BoxLayout
from kivy.utils import escape_markup
class ChatViewer(BoxLayout):
COLORS = \
'#00FF00 #FF00FF #008888 #44AA44 #808080 #FF7722 #6699FF'.split()
def on_kv_post(self, *args, **kwargs):
super().on_kv_post(*args, **kwargs)
self._rv = self.ids.rv.__self__ # rvを何度も参照する事になると思うので直接参照を確保しておく
self._user_color_map = defaultdict(
lambda color_iter=itertools.cycle(self.COLORS): next(color_iter))
def add_msg(self, *, msg:str, who:str):
self._rv.data.append({
'markup': True,
'text': "[b][color={}]{}:[/color][/b] {}".format(
self._user_color_map[who],
who, escape_markup(msg),
),
})
-
on_kv_postはwidgetの初期化が終わった直後に行いたい処理を書くのに適した場所です(__init__()内でsuper().__init__()を呼んだ直後だと初期化が完了している保証が無い)。 -
_user_color_mapは発言者の名前に付ける色を取り出すための辞書です(鍵が発言者の名前で、値が色)。 -
add_msg()は発言があったことをChatViewerに伝えるmethodです。 -
ChatViewerの基底classをRVではなくBoxLayoutとしたのは後でButtonなりTextInputなりを置きたくなるだろうなという想定です。 -
always_overscrollはScrollViewに加えられた新しい機能なのですが、不具合が多いので常に無効(False)にしておくのがお薦めです(RVはScrollViewの派生class)。 -
escape_markup()は発言内容にmarkup言語の制御文字([,],&)が含まれていた場合に制御文字として扱われないようにするために必要です。
実際に使ってみる
できたと思うので実際に動作を確かめてみます。chatなので本来はserverを作って接続なり認証なりをするものだと思いますが、それはRVとは全く関係無いので省き、代わりにrandomな発言を作って定期的にadd_msg()を呼ぶ事にしました。
def _test():
import random
from kivy.clock import Clock
from kivy.app import App
user_names = 'Alice Bob Chris David Elena Frank Georg Helen Ivy Jeff'.split()
msgs = (
"Hello", "What's up?", "All your base are belong to us",
"Python is a programming language that lets you work quickly "
"and integrate systems more effectively. Learn More <<<",
"PEP 492 introduced support for native coroutines and async/await "
"syntax to Python 3.5. It is proposed here to extend Python's "
"asynchronous capabilities by adding support for asynchronous "
"generators.",
"special characters : [ & ]",
)
class SampleApp(App):
def build(self):
return ChatViewer()
def on_start(self):
# 0.1秒毎にadd_random_msg()を呼ぶ
clock_event = Clock.schedule_interval(self.add_random_msg, .1)
# 10秒経ったら呼ぶのを止める
Clock.schedule_once(lambda __: clock_event.cancel(), 10)
def add_random_msg(self, __):
self.root.add_msg(
msg=random.choice(msgs), who=random.choice(user_names))
SampleApp().run()
if __name__ == '__main__':
_test()
この様にあらかじめ用意しておいた名前と発言内容を無作為に組み合わせて発言を作るようにしました。やりたい事はGUI部分のtestなのでこれで十分でしょう。
view widgetの大きさの崩れ
実行するとどうなったかというと

という風に時折view widgetの高さが崩れてしまいました。しかもこれはwindowの大きさを変えたりscrollしたりする事で正しく調整される事もあれば、逆にまた崩れてしまう事もあります。私の経験上こういった症状はやっかいです。何故ならこういったものはpropertyの評価順序やlayoutのtimingのような内部実装に強く依存している可能性が高いからです。なので諦めていたのですが...
対策
幸いにもGitHubで似たような問題を訴えている人が居て、そこでRVの実装者であるmatham氏がかなり詳しく原因と対策を説明してくれていました。そしてそれを参考に以下の行を加える事で崩れを直す事ができました。
<ChatViewer>:
RecycleView:
RecycleBoxLayout:
default_size: None, None # <= この行を加える
このdefault_sizeの初期値は(100, 100)になっていて、これが上の画像でview widgetの高さを時々100pixelにしてしまう原因でした。今回のようにview widgetの
- 幅を
default_size_hint: 1, Noneで - 高さを
height: texture_size[1]で
示している場合、つまりview widgetの大きさの算出に必要な物をdefault_sizeとは別の所で全て示している場合はdefault_sizeは無効(None, None)の方が良いのかもしれません。
おまけ
最後に少し雰囲気を出すために上部にbuttonを加えました。
<ChatViewer>:
orientation: 'vertical'
BoxLayout:
size_hint_y: None
height: self.minimum_height
padding: 5
spacing: 5
canvas.before:
Color:
rgb: .1, .1, .1
Rectangle:
pos: self.pos
size: self.size
Widget:
Button:
text: 'clear'
size_hint_x: None
width: self.texture_size[0] + 10
size_hint_min_y: self.texture_size[1] + 10
on_press: rv.data = [] # A
RecycleView:
# 略

この"clear"buttonを押すことでそれまでに溜まった発言を全て消せます。A行はon_press: rv.data.clear()でも良さそうに見えますがそれだとうまくいきませんでした。はじめはその理由をdataに変更があった事をRVが感知できないため1だと思っていましたが、もしそうであれば
on_press:
rv.data.clear()
rv.refresh_from_data() # dataに変更があった事を明示的に伝える
で動くはずです。でもこれもうまくいかなかったので理由は分からずじまいです。ともかくrv.dataを初期化したい場合はA行のやり方を採る事にします。
まとめ
というわけでview widgetの大きさが揃っていない時もdefault_sizeを正しく設定してあげる事が鍵でした。
追記(20220318)
このChatViewerには幾つか問題が残っているので直しておきます。ひとつは実際にコードを実行するとわかるのですがchatの更新頻度が高いと画面が変わりっぱなしでとても読みづらい事です。なので更新頻度を抑える工夫をします。
画面の更新頻度を抑える
まずはadd_msg()内で直ちにRVを更新する事はせず、いったんlistに蓄えます。
def on_kv_post(self, *args, **kwargs):
...
self._queued_msgs = [] # ここに蓄える
def add_msg(self, *, msg:str, who:str):
self._queued_msgs.append((msg, who, ))
そして1秒後にRVを更新するよう予約します。
def on_kv_post(self, *args, **kwargs):
...
self._queued_msgs = []
self._trigger_update = Clock.create_trigger(self._update, 1)
def add_msg(self, *, msg:str, who:str):
self._queued_msgs.append((msg, who, ))
self._trigger_update() # 一秒後に _update() が呼ばれるよう予約
def _update(self, dt):
self._rv.data.extend( # 溜まったmsgを一気に加える
{
'markup': True,
'text': "[b][color={}]{}:[/color][/b] {}".format(
self._user_color_map[who],
who, escape_markup(msg),
),
} for msg, who in self._queued_msgs
)
self._queued_msgs.clear() # 加え終えたのでこっちは削除
これだけだと単に全体的に更新されるタイミングが一秒遅れるだけで頻度は下がらないように思えますが違います。_trigger_updateに入っているのはClockEventという物なのですが、これの特徴として 既に予約されているときに次の予約が来ても無視する というのがあります。なのでいくら頻りにadd_msg()が呼ばれても_update()は最大で一秒に一回までしか呼ばれません。
安全性に関する心配
外部からやってきた文字列に対する検問がescape_markup()だけなのが気になるので念のためにstr.format()をstring.Templateに代えます。理由はstring.Templateがstr.format()やf文字列とは違って単純な文字列の置き換えしか出来ないぶん安全だと何かの文献で見かけたからです。今回の場合は書式指定文字列自体は直書きで、外部から来た文字列は引数でしかないので無意味かもしれませんが一応。
from string import Template
def _update(self, dt, *, _template=Template(r"[b][color=${color}]${who}:[/color][/b] ${msg}")):
self._rv.data.extend(
{
'markup': True,
'text': _template.substitute(color=self._user_color_map[who], who=who, msg=escape_markup(msg)),
} for msg, who in self._queued_msgs
)
self._queued_msgs.clear()
追記(20241203)
どうやら default_size を None, None にする事で問題が起こる事もあるようです。