自分の家計簿アプリで集計ページの機能追加が発生したのでドリルダウンを使って対応したかったのでメモのついでに共有しておきます。
今回、下記のような年別、月別、日別集計を行えるドリルダウンリスト作ってみます。デザイン要素は全く考慮してないのでご了承ください。使い方によってはディレクトリ階層、組織図などにも応用できる気がします。
ドリルダウンを知らない人に軽く説明すると、要はWindowsエクスプローラのディレクトリツリービューみたいな機能です。なかにどんどん入って行けるやつです。この手のツールは巷にあふれてるのですが自作すると結構面倒です。今回どんな風に実現するのかわかれば入れ子の概念が深まります。

合計行の左の+・ーを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) を表示・非表示するようなプロトタイプをまず作ります。
最終的には拡張アイテムの部分はさらに同じパーツを入れ子にすることで下層のドリルダウンも実現できるよう配慮しています。
- ハンドリング操作
+/ーボタンを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: [] }
]
}
]
}
]


