0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ドリルダウンリストのプロトタイプ設計

0
Posted at

自分の家計簿アプリで集計ページの機能追加が発生したのでドリルダウンを使って対応したかったのでメモのついでに共有しておきます。

今回、下記のような年別、月別、日別集計を行えるドリルダウンリスト作ってみます。デザイン要素は全く考慮してないのでご了承ください。使い方によってはディレクトリ階層、組織図などにも応用できる気がします。

今回説明する完成サンプルイメージです
image.png

ドリルダウンを知らない人に軽く説明すると、要はWindowsエクスプローラのディレクトリツリービューみたいな機能です。なかにどんどん入って行けるやつです。この手のツールは巷にあふれてるのですが自作すると結構面倒です。今回どんな風に実現するのかわかれば入れ子の概念が深まります。
image.png

合計行の左の+・ーをClickすることで配下の内訳を表示・非表示できるようなイメージです。

他のアプリに移植しやすいよう汎用的な設計にするよう意識はしています。

データは下記のような日付(date)と価格(price)だけの非常にシンプルなJSONデータでこれを使って集計表のドリルダウンを実現してみます。

//テストデータ
const TestData=[
    {date: '20260101',price:1000},
    {date: '20260102',price:10000},
    {date: '20260211',price:1000},
    {date: '20260212',price:2000},
    {date: '20260220',price:1000},
    {date: '20260322',price:3000},
    {date: '20260323',price:1000},
    {date: '20260330',price:9000},
    {date: '20250101',price:9000},
    {date: '20240101',price:9000},
]

アイテムのベース骨格

まずドリルダウンの最小骨格は下記のように2x2の表をベースに設計しています。

  • 表内のセル座標系
(0,0) +/- (1,0) 表示アイテム
(0,1) 未使用 (1,1) 拡張アイテム

デフォルトで拡張アイテム行は非表示にし (0,0) の+部分のセルをクリックすることで拡張アイテム行である2行目、すなわち(0,1) 及び(1,1) を表示・非表示するようなプロトタイプをまず作ります。

image.png

最終的には拡張アイテムの部分はさらに同じパーツを入れ子にすることで下層のドリルダウンも実現できるよう配慮しています。

image.png

  • ハンドリング操作

+/ーボタンをClickすることでExpandイベント(handleExpand)を発火しカスタムイベント用のonToggleイベントパラメータのステータスでドリルダウンの挙動を動的に変化させることができるようにしています。

  • ソースコード

では上記をreact-native(typescript)で書いてみます。RowBase(ビュー)を置くだけで上記が実現できているかと思います。

import { View, Text,Button,StyleSheet } from 'react-native';
import { useState } from 'react';


//行アイテムBASEパーツ
export function RowBase({itemView, containerView, onToggle, type, id}){

    const [isVisible, setIsVisible] = useState(false);
    //const [expandText, setExpandText] = useState("+");
    function expandText(){
        return isVisible ? "-":"+"
    }
    const localType= !type ? "": type
    function handleExpand(){
        const sts=!isVisible
       setIsVisible(sts)
       onToggle?  onToggle(sts, localType, id) : null
    }
    return(
        <View style={[styles.containerBase,{}]}>
        <View style={styles.containerRow}>
            <Text style={[styles.dataCell, { flex: 1 }]}  onPress={handleExpand}>{expandText()}</Text>
            <Text style={[styles.dataCell, { flex: 30}]}>{itemView}</Text>
        </View>
        <View style={[styles.containerRow,{height: !isVisible ?  0:'auto' }]}>
            <Text style={[styles.dataCell, { flex: 1 }]}></Text>
            <Text style={[styles.dataCell, { flex: 30, visibility: !isVisible ?  'hidden':'visible' }]}>{containerView}</Text>
        </View>
        
        </View>
    )
}

// スタイル系
const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 2,
    backgroundColor: '#fff',
    
  },
  containerBase: {
    //flex: 1,
    height: "auto",
    flexDirection: 'column',
    padding: 2,
    backgroundColor: '#fff',
    borderWidth: 1,
  },
  containerRow: {
    flexDirection: 'row',
    padding: 0,
    backgroundColor: '#fff',
  },
  dataRow: {
    flexDirection: 'row',
    //borderWidth: 1,
    //borderBottomWidth: 1,
    borderBottomColor: '#ccc',
  },
  dataCell: {
    padding: 2,
    textAlign: 'left',
    borderWidth: 1,
    borderColor: 'lightgray',
  },
})

//テストスクリプト
export function RowBase_Test(){
    function handleExpandTest(sts,type,key){
        console.log(sts,type,key)
    }
    return(
        <RowBase 
            type="test"
            id="MY_ID"
            itemView="表示アイテム"
            containerView = "拡張アイテム"
            onToggle={handleExpandTest} 
        />
    )
}
export default RowBase_Test

応用

この RowBase コンポーネントを使い色々アレンジすると冒頭のドリルダウン対応の合計計算表(DrilDown)が完成します。

実行スクリプトからまず説明しますが下記のような感じでTestDataをDrilDownコンポーネントに渡すことでアプリが動くイメージです。

//テストデータ
const TestData=[
    {date: '20260101',price:1000},
    {date: '20260102',price:10000},
    {date: '20260211',price:1000},
    {date: '20260212',price:2000},
    {date: '20260220',price:1000},
    {date: '20260322',price:3000},
    {date: '20260323',price:1000},
    {date: '20260330',price:9000},
    {date: '20250101',price:9000},
    {date: '20240101',price:9000},
]

export function DrilDownTest2(){
    return(
        <>
        <DrilDown 
            data={TestData}
        />
        </>
    )
}
export default DrilDownTest2

DrilDownモジュール

DrilDown の完成サンプルソースコードを貼っておきます。プログラムコードは解説しませんが、先の RowBase ビューを活用してモジュールを完成させています。再帰的な積算ロジックなのでもう少し本腰を入れれば綺麗に書けると思いますが、今回はドリルダウンがテーマなので雑で許してください。念のため補足すると全データを最初からGUIに反映せず、ボタンを押されるたびに子ノードの明細行を取得する動的ロジックになっています。

なお、下記のソースコードには先に使ったimport文やRowBaseのコード等は省略しています。上のソースコードを足してください。

export function DrilDown({data}){

    const PriceData=data


    // typeキーの値でグループ化
    const groupedYear = Object.groupBy(data, item => item.date.substring(0,4));
     const years = Object.keys(groupedYear); 
    console.log("groupedYear",groupedYear,years)


    // 合計(sum)を計算
    const totals=calc(groupedYear)
    console.log("totals",totals)

    function handleExpandTest(sts,type,key){
        console.log(sts,type,key)
    }
   
    function YearRow({yyyy,data}){
        const [monthView, setMonthView]=useState("")
        const groupedMonth = Object.groupBy(
            data.filter((item)=> item.date.substring(0,4) ===yyyy), 
            item => item.date.substring(0,6));
        const yyyymms = Object.keys(groupedMonth); 
        console.log("YearRow::yyyymms",yyyy,yyyymms,groupedMonth)

        let year_tatal=0
         data.map((item)=>{
             if(item.date.substring(0,4)===yyyy){
              console.log("key",item)
              year_tatal+=item.price
            }
            //year_tatal+=groupedMonth[key].price
        })
        console.log(`${yyyy}年totals`,year_tatal)


        const list=yyyymms.map((yyyymm,index)=>{
                console.log("yyyymms.map yyyymm",yyyymm)
                    return (
                       
                            <MonthRow 
                                yyyymm={yyyymm}
                                data={data}
                            />
                            
                        
                    )
                })
        console.log("list",list)
        const [L, setL]=useState("")
        function expand(sts,type,key){
            console.log("expand()",sts,type,key)
            if(sts){
               //setL(list) 
                setL(
                (<View style={{backgroundColor:'green', flexDirection: 'column', width: '100vh'}}>{list}</View>)
                )   
            }
            else {
                setL("")
            }
        }
        return(
            <>
           
            <RowBase 
                type="year"
                id={yyyy}
                itemView={yyyy+"年 - 合計 : " +year_tatal}
                containerView = {L} //年月リスト
               // onToggle={(sts,type,key)=>handleExpandTest(sts,type,key)} 
                 onToggle={expand} 
            />
            </>
        )
    }
    function MonthRow({yyyymm,data}){
        const [dayView, setDayView]=useState("")
        console.log("MonthRow yyyymm",yyyymm)

        const groupedDay = Object.groupBy(
            data.filter((item)=> item.date.substring(0,6) ===yyyymm), 
            item => item.date);
        const yyyymmdds = Object.keys(groupedDay); 
        console.log("MonthRow::yyyymmdds",yyyymm,yyyymmdds)

        let month_tatal=0
         data.map((item)=>{
             if(item.date.substring(0,6)===yyyymm){
             // console.log("key",item)
              month_tatal+=item.price
            }   
        })
        console.log(`${yyyymm} totals`,month_tatal)

        //let day_tatals=[]
        let day_tatals: Record<string, number> = {};
        data.map((item)=>{
           if(item.date.substring(0,6)===yyyymm){
                console.log("key",item,day_tatals[item.date])
                if(!day_tatals[item.date]) day_tatals[item.date]=0

                day_tatals[item.date]+=item.price
             }   
        })
        console.log(`${yyyymm} day_tatals`,day_tatals)


        const listd=yyyymmdds.map((yyyymmdd,index)=>{
                console.log("yyyymmdds.map yyyymmdd",yyyymmdd)
                 return (
                        <View style={{ flexDirection: "row"}}>
                            <Text>{yyyymmdd +" - 小計 : " +day_tatals[yyyymmdd] }</Text>              
                        </View>
                    )
                })
        console.log("list",listd)
        
        const [L, setL]=useState("")
        function expand(sts,type,key){
           console.log("expand()",sts,type,key)
            if(sts){

                setL(
                    (<View style={{backgroundColor:'lightyellow', flexDirection: 'column'}}>{listd}</View>)
                    )
                }
            else {
                setL("")
            }
        }

        return(
            <RowBase 
                type="month"
                id={yyyymm}
                itemView={yyyymm.substring(0,4)+""+yyyymm.slice(-2)+"月 - 合計 : " +month_tatal }
                containerView = {L}
                //onToggle={(sts,type,key)=>handleExpandTest(sts,type,key)} 
                onToggle={expand} 
            />
        )
    }
    
    return(
        <>
        {
           years.map((year,index)=>{
               return (
                <>
                <YearRow
                    yyyy={year}
                    data={data}
                />
                
                </>
                )
            })
             
        }
        
        </>
    )
}

付録

上記に関連してテストデータ(TestData)を一旦再帰処理加工してからドリルダウンをさせるという手もある。その場合は先のソースコードの途中の難解なロジックが隠蔽され多少は見通しは良くなるかもしれない。ソースコードが汚いのはご容赦ください。ソースコードを見せたいわけでなくドリルダウン実現のアイデアを伝えたいのです。

下記に再帰処理関数 makedata() を添付しておく。
実行後の結果もあとに添付した。

//データの持ち方
interface IData0 {
  date: string;
  price: number;
}

//上記を継承した管理用データ(年月階層管理用)
interface IData extends IData0  {
  childlist: IData[]; 
}



//テストデータ
const TestData : IData0[]=[
    {date: '20260101',price:1000},
    {date: '20260102',price:10000},
    {date: '20260211',price:1000},
    {date: '20260212',price:2000},
    {date: '20260220',price:1000},
    {date: '20260322',price:3000},
    {date: '20260323',price:1000},
    {date: '20260330',price:9000},
    {date: '20250101',price:9000},
    {date: '20240101',price:9000},
]



//再帰処理関数
function makedata(
        tree: IData[],   //ここに階層構造で集計結果を取得
        data: IData0[],   //元データ date,priceのみの辞書配列
        parent:string=null,  //親の日付 yyyy, yyyymm, yyyymmddいずれか
        level=0            //階層レベル
    ) {
    if(level>2) return
    if (parent==null){
        const gRoot = Object.groupBy(data, item => item.date.substring(0,4));
        const roots = Object.keys(gRoot); 
        console.log("gYear",gRoot,roots)
        
        roots.map(
            (root)=>{
                const total = gRoot[root].reduce((acc, cur) => acc + cur.price, 0);
                console.log("root",root,level, total)

                let rootnode: IData[]=[]
                
                tree.push({
                    date: root, 
                    price: total,
                    childlist: rootnode ,
                })
                 //再帰処理
                makedata(rootnode, data, root, level+1)
            }
        )
        return
    }
    const basedata=data.filter((item)=> item.date.substring(0,parent.length) ==parent )
    //console.log(basedata)
    
    const grpSub = Object.groupBy(basedata, item => item.date.substring(0,parent.length+2));
    const subs = Object.keys(grpSub); 
    console.log("grpSub,subs",subs)
    
    
    subs.map(
        (sub)=>{
            // typeキーの値でグループ化
             const total = grpSub[sub].reduce((acc, cur) => acc + cur.price, 0);
            console.log(`sub${level}`,sub ,level,total)

            let subnode: IData[]=[]
            tree.push({
                    date: sub, 
                    price: total,
                    childlist: subnode ,
                })
            //再帰処理
            makedata(subnode, data, sub,level+1)

        }
    )
    
}

let treedata: IData[]=[]    //取得用データ
makedata(treedata, TestData)    //再帰処理開始
 console.dir(treedata, { depth: null }) //結果確認

実行結果

下記の階層で結果を取得している

年別合計リスト
└年月別合計リスト
 └年月日別合計リスト

[
  {
    date: '2024',
    price: 9000,
    childlist: [
      {
        date: '202401',
        price: 9000,
        childlist: [ { date: '20240101', price: 9000, childlist: [] } ]
      }
    ]
  },
  {
    date: '2025',
    price: 9000,
    childlist: [
      {
        date: '202501',
        price: 9000,
        childlist: [ { date: '20250101', price: 9000, childlist: [] } ]
      }
    ]
  },
  {
    date: '2026',
    price: 28000,
    childlist: [
      {
        date: '202601',
        price: 11000,
        childlist: [
          { date: '20260101', price: 1000, childlist: [] },
          { date: '20260102', price: 10000, childlist: [] }
        ]
      },
      {
        date: '202602',
        price: 4000,
        childlist: [
          { date: '20260211', price: 1000, childlist: [] },
          { date: '20260212', price: 2000, childlist: [] },
          { date: '20260220', price: 1000, childlist: [] }
        ]
      },
      {
        date: '202603',
        price: 13000,
        childlist: [
          { date: '20260322', price: 3000, childlist: [] },
          { date: '20260323', price: 1000, childlist: [] },
          { date: '20260330', price: 9000, childlist: [] }
        ]
      }
    ]
  }
]
0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?