Material-UI レスポンシブデザイン- AppBar & Drawer

今までも、必要に応じてMaterial-UIを使ってきましたが、もう少し包括的に勉強したいと思い、その備忘録をまとめようと思いました。主に公式サイトを動かしながら、ソースコードを読み込んでいく形になります。[Drawer Demo - 公式サイト](https://material-ui.com/demos/drawers/)


今回はAppBar & Drawer のレスポンシブデザインを取り上げたいと思います。

※「React入門（翔泳社）」にあった「Yahoo! Shoppingアプリ」に対して、今回使った**レスポンシブデザイン**のテクニックを適用してみました。AWSにアップしたので、スマホかブラウザを縮小して見てください。
http://yahoo-shopping.s3-website-ap-northeast-1.amazonaws.com/


#１．環境設定とソースコード

2月13日前後に、Material-UIのCSSの扱い方が変わったらしく、**Hook API** というものが推奨されているようです。公式サイトのDemo exampleでは、これまでは **Higher-order component API** を使っていましたが、Hook APIを使ったものに置き換えられています。しかもこの新しいソースコードを実行しようとすると、何故かエラーで動作しません。ここでは**Higher-order component API** を使ったソースを採用しています。詳しくは「[CSS in JS(alpha) - Material-UI 公式ドキュメント](https://material-ui.com/css-in-js/basics/)」を参照してください。


このソースコードを実行するには、create-react-appで環境を作り、App.jsを上のものに取り換えればOKです。

```
npx create-react-app material-ui-test
cd material-ui-test
npm install --save @material-ui/core
npm install --save @material-ui/icons
src/App.jsを以下のソースで置き換える
npm start
```


以下がソースコードです。下にいくつかの観点から説明を試みたいと思います。


```React:App.js
import React from 'react';
import PropTypes from 'prop-types';
import AppBar from '@material-ui/core/AppBar';
import CssBaseline from '@material-ui/core/CssBaseline';
import Divider from '@material-ui/core/Divider';
import Drawer from '@material-ui/core/Drawer';
import Hidden from '@material-ui/core/Hidden';
import IconButton from '@material-ui/core/IconButton';
import InboxIcon from '@material-ui/icons/MoveToInbox';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
import MailIcon from '@material-ui/icons/Mail';
import MenuIcon from '@material-ui/icons/Menu';
import Toolbar from '@material-ui/core/Toolbar';
import Typography from '@material-ui/core/Typography';
import { withStyles } from '@material-ui/core/styles';


const drawerWidth = 240;

const styles = theme => ({
  root: {
    display: 'flex',
  },
  drawer: {
    // background: 'yellow',  // 効果なし
    [theme.breakpoints.up('sm')]: {
      width: drawerWidth,  // この幅の横に、mainがレイアウトされる。
      flexShrink: 0,   // !!! これが無いと main が Drawer の下に入り込んでしまう
    },
  },
  appBar: {
    marginLeft: drawerWidth,
    [theme.breakpoints.up('sm')]: {
      width: `calc(100% - ${drawerWidth}px)`,
    },
  },
  menuButton: {
    marginRight: 20,
    [theme.breakpoints.up('sm')]: {
      display: 'none',
    },
  },
  toolbar: theme.mixins.toolbar,
  // Override - Drawerの中で  classes={{ paper: classes.drawerPaper, }}
  drawerPaper: {
    width: drawerWidth,   // drawer.widthに合わせないと無駄な空白ができる
    background: 'yellow', // 効果あり
  },
  content: {
    // marginLeftでappBarに合わせると画面幅を小さくすると無駄な空白ができる。
    // marginLeft: drawerWidth,  // drawer.flexShrink: 0 を使う
    flexGrow: 1,
    padding: theme.spacing.unit * 3,
  },
});


class ResponsiveDrawer extends React.Component {
  state = {
    mobileOpen: false,
  };

  handleDrawerToggle = () => {
    this.setState(state => ({ mobileOpen: !state.mobileOpen }));
  };

  render() {
    const { classes, theme } = this.props;

console.log(JSON.stringify(styles(theme)))  // ** (1)
console.log(theme.breakpoints.up('sm'))     // ** (2)

    const drawer = (
      <div>
        <div className={classes.toolbar} />
        <Divider />
        <List>
          {['Inbox', 'Starred', 'Send email', 'Drafts'].map((text, index) => (
            <ListItem button key={text}>
              <ListItemIcon>{index % 2 === 0 ? <InboxIcon /> : <MailIcon />}</ListItemIcon>
              <ListItemText primary={text} />
            </ListItem>
          ))}
        </List>
        <Divider />
        <List>
          {['All mail', 'Trash', 'Spam'].map((text, index) => (
            <ListItem button key={text}>
              <ListItemIcon>{index % 2 === 0 ? <InboxIcon /> : <MailIcon />}</ListItemIcon>
              <ListItemText primary={text} />
            </ListItem>
          ))}
        </List>
      </div>
    );


    return (
      <div className={classes.root}>
        <CssBaseline />
        <AppBar position="fixed" className={classes.appBar}>
          <Toolbar>
            <IconButton
              color="inherit"
              aria-label="Open drawer"
              onClick={this.handleDrawerToggle}
              className={classes.menuButton}
            >
              <MenuIcon />
            </IconButton>
            <Typography variant="h6" color="inherit" noWrap>
              Responsive drawer
            </Typography>
          </Toolbar>
        </AppBar>
        <nav className={classes.drawer}>
          {/* The implementation can be swapped with js to avoid SEO duplication of links. */}
          <Hidden smUp implementation="css">
            <Drawer
              container={this.props.container}
              variant="temporary"
              anchor={theme.direction === 'rtl' ? 'right' : 'left'}
              open={this.state.mobileOpen}
              onClose={this.handleDrawerToggle}
              classes={{
                paper: classes.drawerPaper,
              }}
            >
              {drawer}
            </Drawer>
          </Hidden>
          <Hidden xsDown implementation="css">
            <Drawer
              classes={{
                paper: classes.drawerPaper,
              }}
              variant="permanent"
              open
            >
              {drawer}
            </Drawer>
          </Hidden>
        </nav>
        <main className={classes.content}>
          <div className={classes.toolbar} />
          <Typography paragraph>
            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
            incididunt ut labore et dolore magna aliqua. Rhoncus dolor purus non enim praesent
            elementum facilisis leo vel. Risus at ultrices mi tempus imperdiet. Semper risus in
            hendrerit gravida rutrum quisque non tellus. Convallis convallis tellus id interdum
            velit laoreet id donec ultrices. Odio morbi quis commodo odio aenean sed adipiscing.
            Amet nisl suscipit adipiscing bibendum est ultricies integer quis. Cursus euismod quis
            viverra nibh cras. Metus vulputate eu scelerisque felis imperdiet proin fermentum leo.
            Mauris commodo quis imperdiet massa tincidunt. Cras tincidunt lobortis feugiat vivamus
            at augue. At augue eget arcu dictum varius duis at consectetur lorem. Velit sed
            ullamcorper morbi tincidunt. Lorem donec massa sapien faucibus et molestie ac.
          </Typography>
          <Typography paragraph>
            Consequat mauris nunc congue nisi vitae suscipit. Fringilla est ullamcorper eget nulla
            facilisi etiam dignissim diam. Pulvinar elementum integer enim neque volutpat ac
            tincidunt. Ornare suspendisse sed nisi lacus sed viverra tellus. Purus sit amet volutpat
            consequat mauris. Elementum eu facilisis sed odio morbi. Euismod lacinia at quis risus
            sed vulputate odio. Morbi tincidunt ornare massa eget egestas purus viverra accumsan in.
            In hendrerit gravida rutrum quisque non tellus orci ac. Pellentesque nec nam aliquam sem
            et tortor. Habitant morbi tristique senectus et. Adipiscing elit duis tristique
            sollicitudin nibh sit. Ornare aenean euismod elementum nisi quis eleifend. Commodo
            viverra maecenas accumsan lacus vel facilisis. Nulla posuere sollicitudin aliquam
            ultrices sagittis orci a.
          </Typography>
        </main>
      </div>
    );
  }
}


ResponsiveDrawer.propTypes = {
  classes: PropTypes.object.isRequired,
  // Injected by the documentation to work in an iframe.
  // You won't need it on your project.
  container: PropTypes.object,
  theme: PropTypes.object.isRequired,
};

export default withStyles(styles, { withTheme: true })(ResponsiveDrawer);
```




![image.png](https://qiita-image-store.s3.amazonaws.com/0/61915/d47372f6-d6fb-8a46-35be-f28056f9a274.png)








#２．FlexBoxによるレイアウト

全体のレイアウトにFlexBoxが使われています。以下のようにContainer要素1つ(div)に、子供のFlex要素３つ(AppBar, nav, main)が存在します。

```
    <div className={classes.root}>
      <AppBar position="fixed" className={classes.appBar}>
      </AppBar>

      <nav className={classes.drawer}>
      </nav>

      <main className={classes.content}>
      </main>
    </div>
```

FlexBoxの指定はCSSに現れます。

```
  root: {
    display: 'flex',
  },
  drawer: {
    [theme.breakpoints.up('sm')]: {
      width: drawerWidth,  // この幅の横に、mainがレイアウトされる。
      flexShrink: 0, // !!! これが無いと main が Drawer の下に入り込んでしまう
    },
  },
  appBar: {
    marginLeft: drawerWidth,
    [theme.breakpoints.up('sm')]: {
      width: `calc(100% - ${drawerWidth}px)`,
    },
  },
  content: {
    // marginLeftでappBarに合わせると画面幅を小さくすると無駄な空白ができる。
    // marginLeft: drawerWidth,  // drawer.flexShrink: 0 を使う
    flexGrow: 1,
    padding: theme.spacing.unit * 3,
  },
```


以下簡単な説明です。実際にいろいろ設定を変えての理解もあるので、間違っていたらご指摘いただければ助かります。


### FlexBoxのContainerとFlex要素

* Container は ＜div className={classes.root}＞
* Flex要素はAppBarとnav、mainの3個


### AppBarについて

* AppBar で position="fixed"  にするとWindow左端からの距離が marginLeft: drawerWidth で正確に指定できる。
* AppBar で position="fixed" にすると AppBarはFlexのレイアウトから外れる。
* 残りのnavとmainの２要素でFlexのレイアウトを分け合う。
* AppBarはstyleで指定されたwidthを占有する。


### navについて

* navは、AppBarのmarginLeft: drawerWidth, で空いたスペースに表示される。
* navの横幅はwidth: drawerWidth,で、 flexShrink: 0,なのでブラウザの横幅を縮めても、navの横幅が縮むことはない。
* flexShrink: 0 を指定しないと、width: drawerWidthを指定しただけでは、何故かmainのコンテンツがnavの下に入り込んでしまう。


### mainについて

* mainは、＜div className={classes.toolbar} /＞で、AppBarと重ならないようにスペースを作っている。
* mainは、flexGrow: 1,で横幅を確保している。 flexGrowの要素はこれ一つなので全空きスペースを独占する。



###教訓

あまり理由を明確に理解できなかったので、以下の2つを教訓として丸呑みします。

* **AppBar では position="fixed"  にする。**
* **navの横幅はwidth: drawerWidth　と flexShrink: 0 とで確保する。**





#３．Drawerのpaper classes

Drawer に classses を指定してCSSを上書き（拡張）します。[Drawer API](https://material-ui.com/api/drawer/)。 paperでDrawerのPaper componentに適用されるCSSを上書きします。

```
      <nav className={classes.drawer}>
            <Drawer
              classes={{
                paper: classes.drawerPaper,
              }}
              variant="permanent"
              open
            >
      </nav>
```

classの定義です。drawerは枠の定義だけで、Drawer要素のPaper ComponentにはdrawerPaperが適用されます。

```
  drawer: {
    // background: 'yellow',  // 効果なし
    [theme.breakpoints.up('sm')]: {
      width: drawerWidth,   // この幅の横に、mainがレイアウトされる。
      flexShrink: 0,
    },
  },

  drawerPaper: {
    width: drawerWidth,   // drawer.widthに合わせないと無駄な空白ができる
    background: 'yellow', // 効果あり
  },
```



#４．theme.mixins.toolbar

theme.mixins.toolbarについてはドキュメントが探せなくて、以下のページを見つけただけでした。
[How does one use or get started with …theme.mixins.toolbar in Material-ui?](https://stackoverflow.com/questions/52995225/how-does-one-use-or-get-started-with-theme-mixins-toolbar-in-material-ui)

これによるとAppBarと併用して使うときに、適切な高さのサイズを教えてくれるものということでした。


それではstylesで指定した以下の定義は、実際にどのようなものに落とし込まれるのかを見てみましょう。

```
  toolbar: theme.mixins.toolbar,
```

以下が、ソースコードの(1)の行で出力したstylesの実態です。toolbarの値としてminHeightとかのサイズが出力されています。

```
{
"root":{"display":"flex"},
"drawer":{"@media (min-width:600px)":{"width":240,"flexShrink":0}},
"appBar":{"marginLeft":240,"@media (min-width:600px)":{"width":"calc(100% - 240px)"}},
"menuButton":{"marginRight":20,"@media (min-width:600px)":{"display":"none"}},

"toolbar":{"minHeight":56,"@media (min-width:0px) and (orientation: landscape)":{"minHeight":48},"@media (min-width:600px)":{"minHeight":64}},

"drawerPaper":{"width":240,"background":"yellow"},
"content":{"flexGrow":1,"padding":24}
}
```

それではどのような使い方がされているのかを見てみましょう。


以下の部分で、nav(Drawer)の頭の部分に、AppBarの高さのサイズだけ、空白を作っています。

```
    const drawer = ( 
      <div>
        <div className={classes.toolbar} />
```


以下の部分でmainの頭の部分に、AppBarの高さのサイズだけ、空白をつくり、mainのコンテンツがAppBarと重ならないようにしています。

```
        <main className={classes.content}>
          <div className={classes.toolbar} />
```





#５．CSS Media Queries でレスポンシブデザイン


レスポンシブデザインとは、どんな大きさの画面でも見やすく、使いやすいWebサイトにするためのものです。[「Material-UIのレスポンシブデザインについては公式サイト」](https://material-ui.com/layout/basics/)を参照してください。

* xs, extra-small: 0px or larger
* sm, small: 600px or larger
* md, medium: 960px or larger
* lg, large: 1280px or larger
* xl, extra-large: 1920px or larger



ここではstylesでの以下の定義に現れる **[theme.breakpoints.up('sm')]** について説明します。

```
  drawer: {
    // background: 'yellow',  // 効果なし
    [theme.breakpoints.up('sm')]: {
      width: drawerWidth,  // この幅の横に、mainがレイアウトされる。
      flexShrink: 0,   // !!! これが無いと main が Drawer の下に入り込んでしまう
    },
```

結論から言えば、[theme.breakpoints.up('sm')] は、**CSS Media Queries**と呼ばれるものの一つで、画面サイズがsm(タブレット）以上のときに有効になるCSSを定義します。


まずオブジェクト初期化子についておさらいしておきましょう。**括弧 []** の意味が分かります。

オブジェクト初期化子 -- MDN Web Docs
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Object_initializer

ECMAScript 2015 から、オブジェクト初期化子構文も計算されたプロパティ名をサポートします。**括弧 []** の中に式を記述でき、それが計算されてプロパティ名として使用されます



次に実際に、**CSS Media Queries**と呼ばれる**theme.breakpoints.up('sm')**関数を実行させると「"@media (min-width:600px)"」という文字列を返すのがわかります。ソースコードの(2)の行で確かめることができます。

```
[theme.breakpoints.up('sm')]　=> "@media (min-width:600px)"
```

以下のような**CSS Media Queries**が利用可能です。
key= xs , sm , md , lg , xl

* theme.breakpoints.up(key)
* theme.breakpoints.down(key)
* theme.breakpoints.only(key)
* theme.breakpoints.between(start, end)




#６．Hidden でレスポンシブデザイン


ここでは、画面のサイズのブレークポイントは、ザックリ言って、xs=モバイル、sm=タブレット、として以下の表のように、2ケースに分けます。これをHiddenを使って、ケースごとにDrawerを定義します。


<table>
<tr><td>モバイル（xsDown）</td><td colspan=4>モバイル以上（smUp）</td></tr>
<tr><td>xs</td><td>sm</td><td>md</td><td>lg</td><td>xl</td></tr>
</table>


詳細は以下を参照してください。
[Hidden - Material-UI公式ドキュメント](https://material-ui.com/layout/hidden/)


以下のソースコードは、モバイル画面とそれ以外の画面で、Drawerの定義を切り替えています。モバイル画面の時は、Drawerは消えてしまい、AppBarのメニューをクリックすることでDrawerがポップアップで現れます。


```
        <nav className={classes.drawer}>
          {/* The implementation can be swapped with js to avoid SEO duplication of links. */}

          {/* タブレット以上なら隠す -- モバイル画面で表示 */}
          <Hidden smUp implementation="css">
            <Drawer
              container={this.props.container}
              variant="temporary"
              anchor={theme.direction === 'rtl' ? 'right' : 'left'}
              open={this.state.mobileOpen}
              onClose={this.handleDrawerToggle}
              classes={{
                paper: classes.drawerPaper,
              }}
            >
              {drawer}
            </Drawer>
          </Hidden>

          {/* モバイル以下なら隠す -- モバイル画面以外で表示 */}
          <Hidden xsDown implementation="css">
            <Drawer
              classes={{
                paper: classes.drawerPaper,
              }}
              variant="permanent"
              open
            >
              {drawer}
            </Drawer>
          </Hidden>
        </nav>
```

### タブレット以上のとき
![image.png](https://qiita-image-store.s3.amazonaws.com/0/61915/061a2331-2094-20e1-4bac-6f3ca413d362.png)


### モバイルのとき（Drawerが消えて、AppBarにメニューが表示される）
![image.png](https://qiita-image-store.s3.amazonaws.com/0/61915/44b846b7-de40-17b8-3215-38540e1b9c5c.png)


### モバイルのとき（メニューをクリックするとDrawerが現れる）
![image.png](https://qiita-image-store.s3.amazonaws.com/0/61915/8116ed15-1b7d-a667-b2e1-9c5262cf663e.png)



今回は以上です。