Chapter 09

第7章 ReduxをUIに反映しよう 前編 -タイトルと記号を表示-

てべすてん
てべすてん
2022.03.11に更新

前章まででReduxのStoreを作ってそれを扱うためのHooksを作りました。がまだ定義しただけで実際に呼び出していないので、それらはUIに反映されていません。
この章ではstoreのデータをUIに反映していきましょう。
(とはいっても各hookを呼び出してJSX内に記述するだけです)

タイトルの反映

まずはタイトルを反映しましょう。

タイトルのstateはuseTitleを使えば簡単に取得できます。

取得したタイトルはヘッダーに表示させたいのでHeader.tsxを編集します。

プロジェクトルート/src/components/App/Header.tsx
  import AppBar, { AppBarProps } from "@mui/material/AppBar";
  import IconButton from "@mui/material/IconButton/IconButton";
  import Toolbar from "@mui/material/Toolbar/Toolbar";
  import MenuIcon from "@mui/icons-material/Menu" ;
  import { FC } from "react";
+ import { useTitle } from "redux/meta/hooks";
+ import TextField from "components/util/TextField";

  export type HeaderProps = {} & AppBarProps;

  const Header: FC<HeaderProps> = (props) => {
+     const {title,setTitle} = useTitle() ;
      return (
          <AppBar position="static">
              <Toolbar>
                  <IconButton
                      size="large"
                      edge="start"
                      color="inherit"
                      sx={{ mr: 2 }}
                  >
                      <MenuIcon />
                  </IconButton>
+                 <TextField value={title} onChange={e=>setTitle(e.target.value)} style={{flexGrow:1}}/> 
              </Toolbar>
            </AppBar>
      );
  };
  export default Header;

反映できました!

記号を編集パネルに表示

続いて記号を表示させていきます。画面上に表示されるアイテムは次の手順で取得するものとします。

  1. storeState.meta.flowsにあるフローを表示する。
  2. フローを表示するときはそのフローのchildrenSymsを縦に並べて表示する。

今回は確認用でフローのIDとそのフローの子記号のIDをMUIのListListItemで表示してみます。

プロジェクトルート/src/components/App/BuildPanel.tsx
import ButtonGroup from "@mui/material/ButtonGroup";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import Button from "components/util/Button";
import {FC} from "react" ; 
import { useItem, useItemOperations, useItems } from "redux/items/hooks";
import { ItemId, Flow, Sym } from "redux/items/types";
import { useFlows } from "redux/meta/hooks";


export interface BuildPanelProps{
}

const BuildPanel :FC<BuildPanelProps> = ({})=>{
  const {
    flows:flowIds,
    addFlow,
  } = useFlows();
  const flows = useItems(flowIds) ;
  const {setItem} = useItemOperations();
  const targetFlowId = flowIds[0] ;
  const targetFlow = useItem(targetFlowId) as Flow;

  //テスト用イベントハンドラ
  const testAddFlow = ()=>{
    //id:"test-flow-(random)"のフローを追加する
    const flowId :ItemId = "test-flow-"+Math.floor(Math.random()*1000) ;
    const flow :Flow = {
      itemType:"test-flow",
      childrenSyms:[],
    } ;
    
    setItem(flowId,flow);
    addFlow(flowId);
  } ;

  const testAddSym = ()=>{
    const symId = "test-sym-"+Math.floor(Math.random()*1000) ;
    const sym :Sym = {
      itemType:"test-item",
      options:[],
    } ;

    setItem(symId,sym);
    setItem(targetFlowId,{
      ...targetFlow,
      childrenSyms:[
        ...targetFlow.childrenSyms,
        symId,
      ]
    } as Flow);
  } ;
  return (
    <div>
      {/* テスト用ボタン */}
      <ButtonGroup>
        <Button onClick={testAddFlow}>フローを追加</Button>
        <Button onClick={testAddSym}>記号を追加して先頭のフローのchildrenSymsに追加</Button>
      </ButtonGroup>

      {/* テスト表示用 */}
      <List>
        {Object.entries(flows).map(([flowId,_item])=>{
          const flow = _item as Flow;
          return (
            <ListItem>
              <h2>
                id:{flowId}
              </h2>
              <List>
                {flow.childrenSyms.map(symId=>(
                  <ListItem>
                    {symId}
                  </ListItem>
                ))}
              </List>
            </ListItem>
          ) ;
        })}
      </List>
    </div>
  )
} 
export default BuildPanel ;

あとはListListItemで表示した部分をアイテム表示専用のコンポーネントに置き換えれば完了です。

アイテムを表示するために軽く設計

ディレクトリ設計時にアイテムに関するファイルはsrc/sym内に配置すると決めていました。

流れ図記号には長方形のもの平行四辺形のものなど様々な形があります。

今回は形ごとにコンポーネントを切り出すことにするので次の表のようにファイルを配置していきましょう。

記述対象 ファイルパス 備考
コンポーネント プロジェクトルート/src/sym/形の名前/コンポーネント名.tsx
アイテムオブジェクト作成関数 プロジェクトルート/src/sym/形の名前/creator.tsx

記号表示用コンポーネントの土台となるコンポーネントを作る

あらかじめ記号の設定値のデフォルト値を定義します。

記号の設定のデフォルト値を定義
const baseSetting = {
    size:{
        width:180,
        height:40,
        lineWidth:2,
    },
    color:{
        fore:"black",
        back:"white",
    },
} ;

いよいよ記号表示用コンポーネントを作成していくわけですが、記号表示用コンポーネントは各形で差があるものの、共通部分が多くあるはずです。なので共通部分を土台コンポーネントに記述し、すべての記号表示用コンポーネントを土台コンポーネント上に作ることで同じようなコードを何度も書く必要がなくなるのでとても良いと思います。

ということで土台コンポーネント(名付けてSymBaseコンポーネント)を作成していきましょう。SymBaseコンポーネントは次のように使用することを理想とします。

SymBaseの利用方法
const child :SymChild = ({item,itemId})=>{
  return <div>{/* 子要素の出力 例えばテキストの表示など */}</div> ;
} ;
const render :SymRenderer = (ctx,setting)=>{ /*settingの情報をもとにctx(キャンバスのコンテキスト)に描画する処理*/ } ;
const RectSym = SymBase(child,render) ;
export default RectSym ;

こうして定義したRectSymを

SymBaseの呼び出し方
<RectSym itemId={/*アイテムID*/} />

のようにReact要素として呼び出すことで

<div class="sym-base">
  <canvas />{/* これにrenderを適用させる */}
  {/* ここにchildを描画する */}
</div>

のようなDOMを出力するものとします。

よってSymBaseの要件は次の3点となります。

  • 引数はchildとrender
    • childには子コンポーネントを指定する
    • renderにはctxとsettingを渡すとctxに図形を描画する処理を記述する
  • 戻り値がFC<{itemId:ItemId}>

これをそのまま実装すると次のようになります。

プロジェクトルート/src/sym/base/SymBase.tsx
import { FC, useEffect } from "react";
import { Item, ItemId } from "redux/items/types";

export type SymChild = FC<{itemId:ItemId,item:Item}> ;
export type SymRender = (
    ctx:CanvasRenderingContext2D,
    setting:{ 
        size:{
            width:number,
            height:number,
            lineWidth:number,
        },
        color:{
            fore:string,
            back:string,
        } 
    }
)=>void ;

const BaseSym = (Child:SymChild, render:SymRender)=>{
    const Sym :FC<{itemId:ItemId}> = ()=>{
        return (
            <div>
                <canvas />  {/* このCanvasにrenderを適用 */}
                {/* ここにchildを描画 */}
            </div>
        ) ;
    } ;
    return Sym ;
} ;

export default BaseSym ;

VSCodeを使っている方はこの時BaseSymをマウスでホバーしてください。SymBaseは引数にChild,renderをとり、戻り値にFC<{itemId: ItemId;}>をとることが確認できると思います。

上のままではせっかく受け取ったchildとrenderをSymに適用させていないのでchild,renderが反映されません。まずはChildを適用させていきましょう。

プロジェクトルート/src/sym/base/SymBase.tsx
  import { FC } from "react";
  import { useItem } from "redux/items/hooks";
  import { Item, ItemId } from "redux/items/types";

  export const baseSetting = {
      size:{
          width:180,
          height:40,
          lineWidth:2,
      },
      color:{
          fore:"black",
          back:"white",
      },
  } ;

  export type SymChild = FC<{itemId:ItemId,item:Item}> ;
  export type SymRender = (
      ctx:CanvasRenderingContext2D,
      setting:{ 
          size:{
              width:number,
              height:number,
              lineWidth:number,
          },
          color:{
              fore:string,
              back:string,
          } 
      }
  )=>void ;

  const BaseSym = (Child:SymChild, render:SymRender)=>{  
      const Sym :FC<{itemId:ItemId}> = ({itemId})=>{
+         const item = useItem(itemId) ;
          return (
              <div>
                  <canvas />
+                 <Child itemId={itemId} item={item} />
              </div>
          ) ;
      } ;
      return Sym ;
  } ;  

  export default BaseSym ;

引数をうけとったitemIdをもとにuseItemでアイテムオブジェクト(item)を取得し、itemId,アイテムオブジェクト(item)をpropsとして渡してChildを呼び出しています。

続いてrenderをcanvasに適用していきます。がこれが少々厄介で...

render関数の定義をもう一度示します。

  • 引数にctx(キャンバスのコンテキスト)とsetting(サイズや色など描画するための情報)を渡す
  • settingの情報をもとにctxに描画する(ctx.strokeRect()などを用いて)
  • 戻り値はなし

この定義に従うと、ctxとsettingを取得してからrender関数を呼び出す必要がありますが、このうちctx(キャンバスのコンテキスト)に関してはキャンバスのDOM要素からしか取得することができないのです。そこでDOM要素を取得するためにReact.useRef()を用います。

useRefの使い方

useRefはDOM要素を取得できるhooksです。
例えばdivタグのDOM要素を取得したいときは次のように記述します。

const ref = useRef<HTMLDivElement>(null) ;
<div ref={ref}>...</div>

ここでrefをconsole.logすると次のようなオブジェクトが表示されます。

refの中身
{
  current:/*DOM要素*/
}

つまりuseRefをもちいてDOM要素を取得するにはrefをdivに渡し、ref.currentにアクセスすればいいです。

ただしDOM要素はuseEffect内でしか取得できない決まりがあります。よってref.currentでアクセスしたいときは必ずuseEffect内でアクセスします。(この理由は今回は割愛します)

とりあえずcanvasを取得してみます

プロジェクトルート/src/sym/base/SymBase.tsx
+ import { FC, useEffect, useRef } from "react";
  import { useItem } from "redux/items/hooks";
  import { Item, ItemId } from "redux/items/types";

  export const baseSetting = {
      size:{
          width:180,
          height:40,
          lineWidth:2,
      },
      color:{
          fore:"black",
          back:"white",
      },
  } ;

  export type SymChild = FC<{itemId:ItemId,item:Item}> ;
  export type SymRender = (
      ctx:CanvasRenderingContext2D,
      setting:{ 
          size:{
              width:number,
              height:number,
              lineWidth:number,
          },
          color:{
              fore:string,
              back:string,
          } 
      }
  )=>void ;

  const BaseSym = (Child:SymChild, render:SymRender)=>{
      const Sym :FC<{itemId:ItemId}> = ({itemId})=>{
          //アイテムの取得 -> Childに渡す
          const item = useItem(itemId) ;
          //render関連の処理
+         const ref = useRef<HTMLCanvasElement>(null) ;
+         useEffect(() => {
+             const canvas = ref.current ;//キャンバスの取得
+         }, []) ;
          return (
              <div>
+                 <canvas
+                   ref={ref} 
+                   width={baseSetting.size.width}
+                   height={baseSetting.size.height}/>
                  <Child itemId={itemId} item={item} />
              </div>  
          ) ;
      } ;
      return Sym ;
  } ;

  export default BaseSym ;

これでキャンバスを取得できました。あとはここからコンテキストを取得し、renderを実行するだけです。

プロジェクトルート/src/sym/base/SymBase.tsx
  import { FC, useEffect, useRef } from "react";
  import { useItem } from "redux/items/hooks";
  import { Item, ItemId } from "redux/items/types";

  export const baseSetting = {
      size:{
          width:180,
          height:40,
          lineWidth:2,
      },
      color:{
          fore:"black",
          back:"white",
      },
  } ;

  export type SymChild = FC<{itemId:ItemId,item:Item}> ;
  export type SymRender = (
      ctx:CanvasRenderingContext2D,
      setting:{ 
          size:{
              width:number,
              height:number,
              lineWidth:number,
          },
          color:{
              fore:string,
              back:string,
          } 
      }
  )=>void ;

  const BaseSym = (Child:SymChild, render:SymRender)=>{
      const Sym :FC<{itemId:ItemId}> = ({itemId})=>{
          //アイテムの取得 -> Childに渡す
          const item = useItem(itemId) ;
          //render関連の処理
          const ref = useRef<HTMLCanvasElement>(null) ;
          useEffect(() => {
              const canvas = ref.current ;
+             if(canvas){
+                 const ctx = canvas.getContext("2d") ;
+                 if(ctx){
+                     const setting = baseSetting ;
  
+                     //renderの呼び出し
+                     render(ctx,setting) ;
                  }
              }
          }, []) ;
          return (
              <div>
                  <canvas 
                      ref={ref}
                      width={baseSetting.size.width}
                      height={baseSetting.size.height}/>
                  <Child itemId={itemId} item={item} />
              </div>
          ) ;
      } ;
      return Sym ;
  } ;

  export default BaseSym ;

これでrenderも呼び出せました。とりあえずはSymBaseは完成です。(後で付け足しがあるかもしれませんが)

SymBaseを使って長方形の記号を表示したい

土台ができたので長方形の記号を表示していこうと思います。長方形は英語でrectangleなのでrectangle symbol略してRectSymと呼ぶことにします。

RectSymコンポーネントを定義します。

プロジェクトルート/src/sym/rect/RectSym.tsx
import SymBase, { SymChild, SymRender } from "sym/base/SymBase";

const Child :SymChild = ({itemId})=>{
    return <div>RectChild:({itemId})</div>
} ;

const render :SymRender = (ctx,{size,color})=>{
    ctx.fillRect(0,0,size.width,size.height)
    ctx.strokeRect(0,0,size.width,size.height);
} ;

const RectSym = SymBase(Child,render) ;

export default RectSym ;

ここでRectSymを表示してみましょう。BuildPanelにListItemで表示していたsymIdを<RectSym itemId={symId} />と表示しましょう。

プロジェクトルート/src/components/App/BuildPanel.tsx
  import ButtonGroup from "@mui/material/ButtonGroup";
  import List from "@mui/material/List";
  import ListItem from "@mui/material/ListItem";
  import Button from "components/util/Button";
  import { FC } from "react" ; 
  import { useItem, useItemOperations, useItems } from "redux/items/hooks";
  import { ItemId, Flow, Sym } from "redux/items/types";
  import { useFlows } from "redux/meta/hooks";
+ import RectSym from "sym/rect/component";


  export interface BuildPanelProps{
  }

  const BuildPanel :FC<BuildPanelProps> = ({})=>{
    const {
      flows:flowIds,
      addFlow,
    } = useFlows();
    const flows = useItems(flowIds) ;
    const {setItem} = useItemOperations();
    const targetFlowId = flowIds[0] ;
    const targetFlow = useItem(targetFlowId) as Flow;
  
    //テスト用イベントハンドラ
    const testAddFlow = ()=>{
      //id:"test-flow-(random)"のフローを追加する
      const flowId :ItemId = "test-flow-"+Math.floor(Math.random()*1000) ;
      const flow :Flow = {
        itemType:"test-flow",
        childrenSyms:[],
      } ;
    
      setItem(flowId,flow);
      addFlow(flowId);
    } ;

    const testAddSym = ()=>{
      const symId = "test-sym-"+Math.floor(Math.random()*1000) ;
      const sym :Sym = {
        itemType:"test-item",
        options:[],
      } ;

      setItem(symId,sym);
      setItem(targetFlowId,{
        ...targetFlow,
        childrenSyms:[
          ...targetFlow.childrenSyms,
          symId,
        ]
      } as Flow);
    } ;
    return (
      <div>
        {/* テスト用ボタン */}
        <ButtonGroup>
          <Button onClick={testAddFlow}>フローを追加</Button>
          <Button onClick={testAddSym}>記号を追加して先頭のフローのchildrenSymsに追加</Button>
        </ButtonGroup>

        {/* テスト表示用 */}
        <List>
          {Object.entries(flows).map(([flowId,_item])=>{
            const flow = _item as Flow;
            return (
              <ListItem>
                <h2>
                  id:{flowId}
                </h2>
                <List>
                  {flow.childrenSyms.map(symId=>(
+                   <RectSym itemId={symId}/>
                  ))}
                </List>
              </ListItem>
            ) ;
          })}
        </List>
      </div>
    )
  }   
  export default BuildPanel ;


スタイルを整えていないので表示の仕方がいびつですが、div内にcanvasと子要素が表示されています。

スタイルを整えましょう。MUIのBoxコンポーネントのsx propsにスタイル情報を渡すことでcssプロパティを指定してスタイルを設定することができます。

プロジェクトルート/src/sym/base/SymBase.tsx
  import Box from "@mui/material/Box";
  import { FC, useEffect, useRef } from "react";
  import { useItem } from "redux/items/hooks";
  import { Item, ItemId } from "redux/items/types";

  export const baseSetting = {
      size:{
          width:180,
          height:40,
          lineWidth:2,
      },
      color:{
          fore:"black",
          back:"white",
      },
  } ;

  export type SymChild = FC<{itemId:ItemId,item:Item}> ;
  export type SymRender = (
      ctx:CanvasRenderingContext2D,
      setting:typeof baseSetting,
  )=>void ;

  const SymBase = (Child:SymChild, render:SymRender)=>{
      const Sym :FC<{itemId:ItemId}> = ({itemId})=>{
          //アイテムの取得 -> Childに渡す
          const item = useItem(itemId) ;
          //render関連の処理
          const ref = useRef<HTMLCanvasElement>(null) ;
          useEffect(() => {
              const canvas = ref.current ;
              if(canvas){
                  const ctx = canvas.getContext("2d") ;
                  if(ctx){
                      const setting = baseSetting ;
                      ctx.fillStyle = setting.color.back ;
                      ctx.strokeStyle = setting.color.fore ;
                      ctx.lineWidth = setting.size.lineWidth ;

                      //renderの呼び出し
                      render(ctx,setting) ;
                  }
              }
          }, []) ;
          return (
+             <Box sx={{position:"relative",width:baseSetting.size.width,height:baseSetting.size,}}>
+                 <Box sx={{position:"absolute",left:0,top:0,width:"100%",height:"100%"}}>
                      <canvas 
                          ref={ref} 
                          width={baseSetting.size.width}
                          height={baseSetting.size.height}/>
+                 </Box>
+                 <Box sx={{position:"absolute",left:0,top:0,width:"100%",height:"100%",display:"flex",justifyContent:"center",alignItems:"center",textAlign:"center"}}>
                      <Child itemId={itemId} item={item} />
+                 </Box>
+             </Box>
          ) ;
      } ;
      return Sym ;
  } ;

  export default SymBase ;

いい感じに表示されいます!残すはフローの表示のみです。

ここまでのコード

この章を執筆中、デモのコードがちょくちょく間違っていたことが発覚しましたので、ここでソースコードをすべてコピーしておくことをお勧めします。

プロジェクトルート/src/sym/base/SymBase.tsx
import Box from "@mui/material/Box";
import { FC, useEffect, useRef } from "react";
import { useItem } from "redux/items/hooks";
import { Item, ItemId } from "redux/items/types";

export const baseSetting = {
    size:{
        width:180,
        height:40,
        lineWidth:2,
    },
    color:{
        fore:"black",
        back:"white",
    },
} ;
const width = baseSetting.size.width ;
const height = baseSetting.size.height ;

export type SymChild = FC<{itemId:ItemId,item:Item}> ;
export type SymRender = (
    ctx:CanvasRenderingContext2D,
    setting:typeof baseSetting,
)=>void ;

const SymBase = (Child:SymChild, render:SymRender)=>{
    const Sym :FC<{itemId:ItemId}> = ({itemId})=>{
        //アイテムの取得 -> Childに渡す
        const item = useItem(itemId) ;
        //render関連の処理
        const ref = useRef<HTMLCanvasElement>(null) ;
        useEffect(() => {
            const canvas = ref.current ;
            if(canvas){
                const ctx = canvas.getContext("2d") ;
                if(ctx){
                    const setting = baseSetting ;
                    ctx.fillStyle = setting.color.back ;
                    ctx.strokeStyle = setting.color.fore ;
                    ctx.lineWidth = setting.size.lineWidth ;
                    
                    //renderの呼び出し
                    render(ctx,setting) ;
                }
            }
        }, []) ;
        return (
            <Box sx={{
                position:"relative",width:width,height:baseSetting.size,
                textAlign:"center",}}>
                    
                <Box sx={{position:"absolute",left:0,top:0,width,height,}}>
                    <canvas 
                        ref={ref} 
                        width={width}
                        height={height}/>
                </Box>
                <Box sx={{position:"absolute",left:0,top:0,width,height,}}>
                    <Child itemId={itemId} item={item} />
                </Box>

            </Box>
        ) ;
    } ;
    return Sym ;
} ;

export default SymBase ;

プロジェクトルート/src/sym/base/RectSym.tsx
import SymBase, { SymChild, SymRender } from "sym/base/SymBase";

const Child :SymChild = ({itemId})=>{
    return <>RectChild:({itemId})</>
} ;

const render :SymRender = (ctx,{size,color})=>{
    ctx.fillRect(0,0,size.width,size.height)
    ctx.strokeRect(0,0,size.width,size.height);
} ;

const RectSym = SymBase(Child,render) ;

export default RectSym ;

質問・指摘などはこちらから

https://zenn.dev/tbsten/scraps/7123b1257c2097