[備忘録] Streamlitでシンプルなかんばんボードを作った話 - ドラッグ＆ドロップなし

## はじめに

今回はStreamlitでかんばんボードを実装する際に、当初考えていたドラッグ＆ドロップ機能からボタンベースのUIへと方針転換した経緯を備忘録として残しておきます。

「Trelloみたいなかんばんボードを自分用に作りたい」という単純な動機から始まったこのプロジェクト。検討の結果、Streamlitの強みを活かせるシンプルなアプローチを選びました。

## 当初の計画：ドラッグ＆ドロップ機能

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/3b1ddc54-e940-430e-aa04-3223d6eccfce.png)

最初はTrelloのようなドラッグ＆ドロップのある本格的なUIを考えていました。いくつかの実装方法を調べたところ：

1. **Streamlitカスタムコンポーネント** 
   - React Beautiful DnDを使った実装
   - コンポーネント開発環境のセットアップが必要

2. **HTML/JavaScript注入**
   - `st.components.v1.html`を使ったJavaScript利用
   - Sortable.jsなどのライブラリを組み込む

3. **iframeによる表示**
   - 外部のHTMLページをiframeで埋め込む

## Streamlitの特性を考慮した判断

調査を進めるうちに、以下のような点が見えてきました：

1. **Streamlitの強みとのミスマッチ**
   - Streamlitはデータ分析・可視化に強みを持つフレームワーク
   - 複雑なUIよりもデータフローと状態管理がシンプル

2. **開発コストと効果のバランス**
   - カスタムコンポーネント開発は学習コストが高い
   - 個人タスク管理という用途に対して開発コストが見合わない

3. **メンテナンス性**
   - Streamlitのバージョンアップに合わせた調整が必要になる可能性

これらを考慮した結果、「Streamlitの良さを活かしつつ、必要な機能を実現できるシンプルな方法」を選ぶほうが合理的と判断しました。

## 方針転換：Streamlitの強みを活かしたUI設計

Streamlitの特性を活かし、以下の点を重視した設計に切り替えました：

1. **シンプルなデータモデルと状態管理**
   - `st.session_state`を使った直感的な状態管理
   - リストとディクショナリだけのシンプルなデータ構造

2. **明確な操作方法**
   - 左右の矢印ボタンによるタスク移動
   - サイドバーでのタスク追加と設定

3. **カスタムCSSによる見た目の向上**
   - ダークモードのモダンなUI
   - カラムとカードのスタイリング


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/766d69c6-8f90-4434-8a91-92b5db8617d1.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/bf51160e-a647-4bed-89f0-b15cf4ae3a9a.png)

## 最終的な実装コード

```python
import streamlit as st
import json
from uuid import uuid4

# アプリのタイトルとスタイル
st.set_page_config(page_title="かんばんボード", layout="wide")
st.markdown("""
<style>
    .main {
        background-color: #121212;
        color: white;
    }
    .stButton > button {
        width: 100%;
    }
    .card {
        background-color: #2D2D2D;
        border-radius: 5px;
        padding: 10px;
        margin-bottom: 10px;
        color: white;
    }
    .column {
        background-color: #1E1E1E;
        border-radius: 10px;
        padding: 10px;
        height: 500px;
        overflow-y: auto;
    }
    .column-header {
        text-align: center;
        padding: 10px 0;
        font-size: 18px;
        font-weight: bold;
        border-bottom: 1px solid #333;
        margin-bottom: 10px;
    }
</style>
""", unsafe_allow_html=True)

# アプリのタイトル
st.title("かんばんボード")

# セッション状態の初期化
if 'notes' not in st.session_state:
    st.session_state.notes = [
        {"id": str(uuid4()), "text": "カレーをつくる", "status": "未着手"},
    ]
if 'statuses' not in st.session_state:
    st.session_state.statuses = ["未着手", "進行中", "完了"]

# サイドバーで新しい付箋の追加
with st.sidebar:
    st.header("新しいタスクを追加")
    note_text = st.text_area("内容", height=100)
    status = st.selectbox("ステータス", st.session_state.statuses)
    
    if st.button("追加"):
        if note_text:
            st.session_state.notes.append({
                "id": str(uuid4()),
                "text": note_text,
                "status": status
            })
            st.success("タスクが追加されました！")
            try:
                st.rerun()
            except:
                st.experimental_rerun()

    # データの管理セクション
    st.header("データ管理")
    if st.button("データをリセット"):
        st.session_state.notes = [
            {"id": str(uuid4()), "text": "カレーをつくる", "status": "未着手"},
        ]
        st.success("データがリセットされました！")
        try:
            st.rerun()
        except:
            st.experimental_rerun()

    # カラムの管理
    st.header("ステータスの管理")
    new_status = st.text_input("新しいステータス")
    if st.button("ステータスを追加") and new_status:
        if new_status not in st.session_state.statuses:
            st.session_state.statuses.append(new_status)
            st.success(f"「{new_status}」が追加されました")
            try:
                st.rerun()
            except:
                st.experimental_rerun()

    status_to_remove = st.selectbox("削除するステータス", 
                                   st.session_state.statuses,
                                   key="remove_status")
    if st.button("ステータスを削除"):
        if len(st.session_state.statuses) > 1:
            # 削除するステータスのタスクを最初のステータスに移動
            for note in st.session_state.notes:
                if note["status"] == status_to_remove:
                    note["status"] = st.session_state.statuses[0]
            
            st.session_state.statuses.remove(status_to_remove)
            st.success(f"「{status_to_remove}」が削除されました")
            try:
                st.rerun()
            except:
                st.experimental_rerun()
        else:
            st.error("少なくとも1つのステータスが必要です")

# メインコンテンツ: カラムとカードの表示
cols = st.columns(len(st.session_state.statuses))

for i, status in enumerate(st.session_state.statuses):
    with cols[i]:
        # カラムヘッダー
        st.markdown(f"<div class='column-header'>{status}</div>", unsafe_allow_html=True)
        
        # このステータスのカード
        status_notes = [note for note in st.session_state.notes if note["status"] == status]
        
        if not status_notes:
            st.markdown(f"<div style='text-align: center; color: #666; padding: 20px 0;'>"
                       f"{status + 'のタスクはありません' if status == '完了' else 'タスクをここに追加'}"
                       f"</div>", unsafe_allow_html=True)
        
        # カードとアクションボタンを表示
        for note in status_notes:
            # カードスタイルのコンテナ
            card_container = st.container()
            card_container.markdown(f"<div class='card'>{note['text']}</div>", unsafe_allow_html=True)
            
            # 移動ボタン
            col1, col2 = st.columns(2)
            
            # 前のステータスに移動
            if st.session_state.statuses.index(status) > 0:
                prev_status = st.session_state.statuses[st.session_state.statuses.index(status) - 1]
                if col1.button(f"← {prev_status}", key=f"prev_{note['id']}"):
                    note["status"] = prev_status
                    try:
                        st.rerun()
                    except:
                        st.experimental_rerun()
            
            # 次のステータスに移動
            if st.session_state.statuses.index(status) < len(st.session_state.statuses) - 1:
                next_status = st.session_state.statuses[st.session_state.statuses.index(status) + 1]
                if col2.button(f"{next_status} →", key=f"next_{note['id']}"):
                    note["status"] = next_status
                    try:
                        st.rerun()
                    except:
                        st.experimental_rerun()

# 使い方のセクション
with st.expander("使い方"):
    st.markdown("""
    ### 基本的な使い方:
    1. **タスクの追加**: サイドバーで内容とステータスを入力して「追加」ボタンをクリックします。
    2. **タスクの移動**: 各カードの下にある矢印ボタンをクリックして、ステータスを変更できます。
    
    ### 高度な機能:
    - **ステータスの管理**: サイドバーの「ステータスの管理」セクションで新しいステータスを追加したり、既存のステータスを削除したりできます。
    - **データのリセット**: サイドバーの「データをリセット」ボタンでデータを初期状態に戻せます。
    """)

# フッター
st.markdown("---")
st.markdown("© 2025 かんばんボード")
```

画面の例 :
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/5a06fbdb-6b4a-4ffc-8f54-8aa989ac504e.png)

## 実装から得た気づき

### ボタンベースUIの意外な使いやすさ

最初はドラッグ＆ドロップが必須だと思っていましたが、実際に使ってみると左右の矢印ボタンは以下の点で意外に使いやすいことがわかりました：

1. **操作の明確さ**
   - ボタンの方が操作が明示的で迷いがない
   - 特にモバイルでは細かいドラッグ操作より正確

2. **実装のシンプルさ**
   - コードが理解しやすく、バグも少ない
   - Streamlitの再描画モデルとの相性も良い

3. **拡張性**
   - 機能追加が容易（例：タグ追加、優先度変更など）
   - バージョンアップによる影響を受けにくい

### Streamlitの特性とうまく付き合う

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/d806bcfa-628c-4b25-9d15-61d1f4f9024e.png)

Streamlitは以下のような特性があり、それを活かした設計が重要だと再認識しました：

1. **サーバーサイドレンダリング**
   - 状態変更→再描画のモデルを理解する
   - 複雑なクライアントサイド処理は避ける

2. **シンプルなデータフロー**
   - st.session_stateを中心にしたデータ管理
   - 複雑なデータ構造より単純な構造が扱いやすい

3. **高速プロトタイピング**
   - 完璧な実装より、まず動くものを作る
   - 徐々に機能を追加していくアプローチ

## 「いつか」やってみたい改善アイデア

基本機能は十分ですが、暇があったらこんな機能も追加できそうですね：

1. **データのエクスポート/インポート**
   - 「あれ？リロードしたらデータ消えた！」問題の解決策
   - JSONでサクッと保存/読込みができると便利かも

2. **タグ機能**
   - 「仕事」「プライベート」とかでタグ付けできたら整理しやすそう
   - あると便利だけど、なくても十分使える

3. **期限表示**
   - 急ぎのタスクがわかりやすくなる
   - 「あと3日！」みたいな警告があると面白いかも

4. **ダークモード/ライトモード切替**
   - 目に優しいのはいいけど、昼間は明るい方がいい時もある
   - カスタムテーマもあれば気分転換になりそう

## まとめ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/b9761272-7b5f-4aa7-9b3d-707b88826778.png)

Streamlitでかんばんボードを実装する過程で、「フレームワークの特性を理解し、その強みを活かす」ことの重要性を改めて感じました。必ずしも複雑なUIが必要なわけではなく、目的に合った使いやすさを重視することで、シンプルながらも実用的なアプリケーションが作れることがわかりました。

Streamlitは短時間で実用的なアプリを作るのに非常に適しており、その特性を理解して設計すれば、ドラッグ＆ドロップのような複雑なUI機能がなくても十分に実用的なアプリケーションが開発できます。

今回作成したかんばんボードは、個人のタスク管理用途としては十分な機能を実装できました。まずはシンプルに始めて、必要に応じて機能を追加していく - そんなアプローチがStreamlitとの相性が良いと感じました。

## 参考
上記の記事では標準のStreamlitでリスト並び替えを実装しましたが、
ドラッグ＆ドロップ対応の「Streamlit Sortables」というコンポーネントもあります。
より直感的な操作を求める場合に便利そうです！
動作例の動画もGitHubにありますので、興味ありましたらぜひご覧ください👇

https://github.com/ohtaman/streamlit-sortables

## 免責事項
本記事の作成にあたり、文章や図解の生成にClaude Sonnetを活用しました。最終的な編集と確認は筆者が行っています。