【Streamlit】カスタムコンポーネントの作り方

## はじめに
StreamlitはPythonだけで、Webアプリケーションが作成できるフレームワークです。
あらかじめUIのコンポーネントが用意されていますが、必要に応じてコンポーネントを自作することもできます。

今回は簡単なコンポーネントの作成からPyPIへの公開までの手順をまとめました。

## 今回作成したコンポーネント
よくあるアバターアイコンを表示するものです。
Streamlitには何故かない。。データアプリには不要というとこですかね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/649608/597e0414-95ea-537f-fc3b-2cdf0d5022c5.png)


- PyPI

https://pypi.org/project/streamlit-avatar/

```bash
pip install streamlit-avatar
```

- Github

https://github.com/ppspps824/streamlit_avatar




## 環境構築

PythonおよびStreamlit、Node.jsが必要となります。が、それぞれ数多くの導入記事があるのでそちらにお任せします。

## テンプレートのクローン
[公式のテンプレート](https://github.com/streamlit/component-template/)があるのですが、このままだと色々混ざっているので今回は最小構成のプロジェクトにします。




``` bash
git clone https://github.com/ppspps824/streamlit_avatar.git
```

## コンポーネント作成

### バックエンド側

```python:__init__.py
import os

import streamlit.components.v1 as components

_RELEASE = False # デバッグ時はFalseにしておく

if not _RELEASE:
    _component_func = components.declare_component(
        "streamlit_avatar",
        url="http://localhost:3001",
    )
else:
    parent_dir = os.path.dirname(os.path.abspath(__file__))
    build_dir = os.path.join(parent_dir, "frontend/build")
    _component_func = components.declare_component("streamlit_avatar", path=build_dir)

# Pythonスクリプト内で呼び出す関数。通常通り引数もここで指定。
# defaultは初期状態での戻り値
def avatar(data_list):
    component_value = _component_func(data_list=data_list, default=None)
    return component_value
```

### フロントエンド側
スタイリング部分でごちゃっとしてますが、基本は```this.props.args["xxx"]```でバックエンドから受け取ったパラメータを利用できます。(今回はdata_listを受け取っています。)

```typescript:index.tsx
import React from "react"
import ReactDOM from "react-dom"
import Avatar from "./Avatar"

ReactDOM.render(
  <React.StrictMode>
    <Avatar />
  </React.StrictMode>,
  document.getElementById("root")
)
```

```typescript:Avatar.tsx
import {
  Streamlit,
  StreamlitComponentBase,
  withStreamlitConnection,
} from "streamlit-component-lib"
import React, { ReactNode, CSSProperties } from "react"

interface DataItem {
  title: string;
  url: string;
  size: number;
  caption: string;
  key: string;
}

interface AvatarProps {
  args: {
    data_list: DataItem[];
  };
}

class Avatar extends StreamlitComponentBase<AvatarProps> {
  public render = (): ReactNode => {
    const dataList: DataItem[] = this.props.args["data_list"] || [];

    const container_styles: CSSProperties = {
      display: "flex",
      flexDirection: "column",
    };

    return (
      <div style={container_styles}>
        {dataList.map((data, index) => (
          <AvatarComponent
            key={index}
            data={data}
            onClicked={this.onClicked}
          />
        ))}
      </div>
    )
  }

  private onClicked = (title: string, caption: string, key: string): void => {
    Streamlit.setComponentValue({ "title": title, "caption": caption, "key": key })
  }
}

interface AvatarComponentProps {
  data: DataItem;
  onClicked: (title: string, caption: string, key: string) => void;
}

class AvatarComponent extends React.Component<AvatarComponentProps> {
  render() {
    const { data, onClicked } = this.props;

    const container_styles: CSSProperties = {
      display: "flex",
      alignItems: "center",
      margin: "0.5rem"
    };
    const img_styles: CSSProperties = {
      width: data.size,
      height: data.size,
      borderRadius: "50%",
      objectFit: "cover",
      marginRight: "10px",
    };
    const text_container_styles: CSSProperties = {
      display: "flex",
      flexDirection: "column",
    };
    const title_styles: CSSProperties = {
      fontWeight: "bold",
    };
    const caption_styles: CSSProperties = {
      fontSize: "12px",
      color: "gray",
      marginBottom: 0,
    };

    return (
      <div style={container_styles} onClick={() => onClicked(data.title, data.caption, data.key)}>
        <img src={data.url} alt="avatar" style={img_styles} />
        <div style={text_container_styles}>
          <div style={title_styles}>{data.title}</div>
          <p style={caption_styles}>{data.caption}</p>
        </div>
      </div>
    )
  }
}

export default withStreamlitConnection(Avatar)


```

## テスト

### フロントエンドサーバの起動

``` bash
cd frontend
npm run start
```

### バックエンドサーバの起動
テスト用のスクリプトを作って
```python:app.py
import streamlit as st
from streamlit_avatar import avatar

result = avatar(
    [
        {
            "url": "https://picsum.photos/id/237/300/300",
            "size": 40,
            "title": "Sam",
            "caption": "hello",
            "key": "avatar1",
        },
        {
            "url": "https://picsum.photos/id/238/300/300",
            "size": 40,
            "title": "Bob",
            "caption": "happy",
            "key": "avatar2",
        },
        {
            "url": "https://picsum.photos/id/23/300/300",
            "size": 40,
            "title": "Rick",
            "caption": "Bye",
            "key": "avatar3",
        },
    ]
)
st.write(result)

```
起動します。
``` bash
streamlit run app.py
```

こんな感じで表示されるかと思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/649608/597e0414-95ea-537f-fc3b-2cdf0d5022c5.png)

## PyPIへ公開

こちらの記事を参考にさせていただきましたが、いくつか特有の注意点があります。

https://zenn.dev/sikkim/articles/490f4043230b5a

### フロントエンドのビルド

```bash
cd frontend
npm build
```

### \_\_init\_\_.py
以下に変更しておきます。
```python:__init__.py
_RELEASE = True
```

### MANIFEST.in
以下のように指定してbuildにfrontendが含まれるようにします。
```in:MANIFEST.in
recursive-include <コンポーネント名>/frontend/build *
```

## おわりに
コンポーネントを使って公開するのは私自身も初めてでしたが思った以上に簡単でした。
皆さんもぜひ！

https://discuss.streamlit.io/c/streamlit-components/