1
1

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 1 year has passed since last update.

RecycleView攻略 2日目 chat viewerを作る

1
Last updated at Posted at 2020-12-11

今回はRVを使って以下のようなchat viewerを作ってみます。
Screenshot at 2020-12-10 16-31-24.png
前回作ったものとは違って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_overscrollScrollViewに加えられた新しい機能なのですが、不具合が多いので常に無効(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の大きさの崩れ

実行するとどうなったかというと
Screenshot at 2020-12-11 14-45-33.png
という風に時折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:
        # 略

Screenshot at 2020-12-11 13-54-27.png
この"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.Templatestr.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()

source code

追記(20241203)

どうやら default_sizeNone, None にする事で問題が起こる事もあるようです。

  1. ObservableList実装を見て分かる通りclear()は上書きされていない

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?