前回のあらすじ

  • Gatsbyのプラグイン紹介
  • トラブル話

この記事ですること

  • Material-UIを用いたフロントエンドのデザイン

導入

https://material-ui.com/
https://www.gatsbyjs.com/plugins/gatsby-plugin-material-ui/

yarn add gatsby-plugin-material-ui @material-ui/core

インストールしたら前回の記事に書いたようにgatsby-config.jsに設定を追記。
これだけなのだが、今回はstyled-componentsも併用していくので以下のコマンドも打っておく。

https://styled-components.com/ https://www.gatsbyjs.com/docs/styled-components/

yarn add gatsby-plugin-styled-components styled-components babel-plugin-styled-components

こちらも前回の記事通り設定を追記する。

何が出来るのか

Material-UI

タグを書くだけでで綺麗なUIが出来ます。

ex)

<Button variant="contained" color="primary">
  Primary
</Button>

styled-components

CSSを適用したオリジナルタグが作れます。

ex)

const Content = styled.div`

h1, h2, h3, h4, h5, h6 {
	margin: 0 0 20px 0 !important;
}

*+h1, *+h2, *+h3, *+h4, *+h5, *+h6 {
    margin: 40px 0 20px 0 !important;
}

`;
<Content>
    <h2>テスト</h2>
</Content>

これでデザインが適用されたdivが出来上がる。

合わせると…

綺麗なUIを自分好みに微調整して簡単にオリジナルのタグが作れます。

私はNuxt+vuetifyの構成を使ったことがある。
その構成と比較すると、難易度が少し上がった代わりに柔軟性が増した印象を受けた。
Reactを使いこなす人にとっては基礎の基礎だろうから難しいといっても私基準だが。

実際に使ってみた

まずはお約束の書き方。

import { ThemeProvider, createMuiTheme, responsiveFontSizes } from "@material-ui/core";
import { ThemeProvider as StyledThemeProvider } from "styled-components";


let theme = createMuiTheme({
  //テーマの定義
  palette: {
    type: 'light' // 'light'(default) or 'dark'
  },
  // etc...
});

theme = responsiveFontSizes(theme);

const Layout = ({ children, title = "" }: IProps) => {
  return (
    <ThemeProvider theme={theme}>
      <StyledThemeProvider theme={theme}>
        <Seo lang="ja" title={title} />
        <Content children={children} />
      </StyledThemeProvider>
    </ThemeProvider>
  )
}

両方のライブラリでテーマを統一したいので、styled-componentsにMaterial-UIのテーマを渡しておく。
テーマっていうのはMaterial-UI内の部品の色味やサイズなどをある程度統一してくれるもの。
テーマを変えれば全体のデザインが連動して変わってくれるように作っていくと楽そうなので使っていく。
フォントやレスポンシブ対応など様々な設定が出来るが、本題ではないので詳しくは公式のURLから調べてほしい。
https://material-ui.com/customization/theming/

実際に使う。

const Heading = styled(Typography)`
		display: flex;
		align-items: center;
		background-color: ${props => props.theme.palette.primary.main};
		color: white;
		padding: 10px;
`;
<Heading variant="h2">
    <AcUnitIcon style={{ marginRight: "0.25em" }} />
    {children}
</Heading>

こんな感じでMaterial-UIのタグをstyled-componentsでカスタマイズする。
タグで囲っているところにデザインが適用される。
この場合だとTypographyというMaterial-UIのタグに対して、色や内部の配置を指示している。
結果的に今ブログで使っているH2タグが出来るわけだ。

何が便利かというと、jsの1コンポーネント内でcssがスコープされて完結するのが良い。
グローバルのsassに書くとかいちいちファイル分けてインポートとか面倒くさいもんね。
こういうのをcss in jsというらしい。
それに加えてタグ的に書けるのが、ページ内のより限定範囲への適用に対して書きやすく見通しが良い。
本来classとかidとか振らないといけないもんね。

よく使うタグ

Box

https://material-ui.com/components/box/
設定しやすいdivみたいな形で使う。
marginやpaddingを画面幅によって変える時に便利。
それ以外にもとりあえず括っときたいときには使う。

Grid

https://material-ui.com/components/grid/
グリッドレイアウトのお供。
レスポンシブしたいときに重宝する。

Typography

https://material-ui.com/components/typography/
文字はとりあえずこれで囲っておく。
themeでフォントの種類や大きさを細かくいじれるので統一感が出る。

あとはDrawerとCardも使ったがこちらはピンポイントで用途がある感じなので、上の3つを抑えておけばなんとかなる。
後はthemeの設定項目を理解しておくこと。

デザイン例とか

ここからはブログの部品をいくつかピックアップして話して行こうと思う。
ちなみにMaterial-UI依存でstyled-componentsでいじっている所はほぼ無い。

記事一覧

const Articles = ({ articles }: IProps) => {
  const classes = useStyles();
  return (
    <Grid container spacing={3} className={classes.root}  alignItems="stretch">
      {articles.map((article: StrapiArticleEdge, i: Number) => {
        return (
          <Grid item xs={12} md={6} xl={4} style={{display: 'flex'}} key={article.node.id}>
              <ArticleCard article={article} key={"article__${article.node.id}"} />
          </Grid>
        )
      })}
    </Grid>

  )
}

記事をflexで横並びにしているだけだ。
一番深い階層のGridを見れば分かる通りここで横にいくつ並べるかのレスポンシブ適用を指示している。

記事カード

const useStyles = makeStyles({
  root: {
    maxWidth: 600,
    height: "100%",
    display: 'flex',
    justifyContent: 'space-between',
    flexDirection: 'column'
  },
  cardActionArea: {
    height: "100%",
    display: 'flex',
    justifyContent: 'space-between',
    flexDirection: 'column'
  },
  cardaMedia: {
    margin: "auto 0",
    maxHeight: "300px"
  },
  cardLink:{
    height: "100%"
  },
  cardAction: {
    marginLeft: "auto"
  },
  title: {
    textAlign: "center"
  }
});

export default function ArticleCard({ article }: IProps) {
  const classes = useStyles();

  return (
    <Card className={classes.root}>
      <Link to={`/articles/${article.node.strapiId}`} className={classes.cardLink}>
        <CardActionArea className={classes.cardActionArea}>
          <CardMedia className={classes.cardaMedia}
            component="img"
            alt={article.node.image ? article.node.image.publicURL ? article.node.image.publicURL : noimage : noimage}
            height="140"
            image={article.node.image ? article.node.image.publicURL ? article.node.image.publicURL : noimage : noimage}
            title={article.node.title}
          />
          <CardContent>
            <Typography gutterBottom variant="subtitle2" className={classes.title}>
              {article.node.category ?
                (
                  <Link to={`/categories/${article.node.category.id}`}>
                    {article.node.category.name}
                  </Link>
                )
                : ("")}
            </Typography>
            <Typography gutterBottom variant="h3" component="h2" className={classes.title}>
              {article.node.title}
            </Typography>
            <Typography variant="body1" color="textSecondary" component="p">
              {article.node.childMarkdownRemark.excerpt}
            </Typography>
          </CardContent>
        </CardActionArea>
      </Link>
      <CardActions className={classes.cardAction}>
        <Moment format="YYYY-MM-DD">{article.node.published_at}</Moment>
      </CardActions>
    </Card>
  );
}

カードの中にTypographyがいっぱい…
タグと適用するデザインは別で指定できるが、まあ共通の方がわかりやすい。
bodyやsubtitleなども指定できるので、本文の文字スタイルだけ変えたいとかいった場合にも楽できる。

ヘッダー+ドロワー

長いけどカットしたら意味が分からなかったので全部載せる。
適当に読み飛ばしてくれ。

const drawerWidth = 240;

function HideOnScroll(props: Props) {
  const { children, window } = props;
  // Note that you normally won't need to set the window ref as useScrollTrigger
  // will default to window.
  // This is only being set here because the demo is in an iframe.
  const trigger = useScrollTrigger({ target: window ? window() : undefined });

  return (
    <Slide appear={false} direction="down" in={!trigger}>
      {children}
    </Slide>
  );
}

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      display: 'flex',
      flexDirection: "column",
    },
    appBar: {
      transition: theme.transitions.create(['margin', 'width'], {
        easing: theme.transitions.easing.sharp,
        duration: theme.transitions.duration.leavingScreen,
      }),
      '& a': {
        color: 'White',
        '&:hover': {
          color: 'White',
        }
      },
    },
    appBarShift: {
      width: `calc(100% - ${drawerWidth}px)`,
      transition: theme.transitions.create(['margin', 'width'], {
        easing: theme.transitions.easing.easeOut,
        duration: theme.transitions.duration.enteringScreen,
      }),
      marginRight: drawerWidth,
    },
    title: {
      flexGrow: 1,
      height: "100%"
    },
    hide: {
      display: 'none',
    },
    drawer: {
      width: drawerWidth,
      flexShrink: 0,
      '& a': {
        color: 'black',
        '&:hover': {
          color: 'black',
        }
      },
    },
    drawerPaper: {
      width: drawerWidth,
    },
    drawerHeader: {
      display: 'flex',
      alignItems: 'center',
      padding: theme.spacing(0, 1),
      // necessary for content to be below app bar
      ...theme.mixins.toolbar,
      justifyContent: 'flex-start',
    },
    content: {
      flexGrow: 1,
      transition: theme.transitions.create('margin', {
        easing: theme.transitions.easing.sharp,
        duration: theme.transitions.duration.leavingScreen,
      }),
    },
    contentShift: {
      transition: theme.transitions.create('margin', {
        easing: theme.transitions.easing.easeOut,
        duration: theme.transitions.duration.enteringScreen,
      }),
      // [theme.breakpoints.up('md')]: {
      //   marginRight: drawerWidth,
      // },
    },
  }),
);

interface Props {
  /**
   * Injected by the documentation to work in an iframe.
   * You won't need it on your project.
   */
  window?: () => Window;
  children: React.ReactNode;
}


export default function Content(props: Props) {
  const { children } = props
  const classes = useStyles();
  const theme = useTheme();
  const [open, setOpen] = React.useState(false);

  const handleDrawerOpen = () => {
    setOpen(true);
  };

  const handleDrawerClose = () => {
    setOpen(false);
  };

  const drawer = (
    <div>
      <div className={classes.drawerHeader}>
        <IconButton onClick={handleDrawerClose}>
          {theme.direction === 'rtl' ? <ChevronLeftIcon /> : <ChevronRightIcon />}
        </IconButton>
      </div>
      <Divider />

      <Typography variant="subtitle2" noWrap style={{ fontFamily: "Staatliches", margin: "1em" }}>PAGE</Typography>
      <PageContent />
      <Divider />
      <Typography variant="subtitle2" noWrap style={{ fontFamily: "Staatliches", margin: "1em" }}>CATEGORY</Typography>
      <CategoryContent />
      <Divider />

    </div>
  );

  return (
    <div className={classes.root}>
      <CssBaseline />
      <HideOnScroll {...props}>
        <AppBar
          position="sticky"
          className={clsx(classes.appBar, {
            [classes.appBarShift]: open,
          })}
        >
          <Toolbar>
            <StaticQuery
              query={graphql`
                query {
                  site {
                    siteMetadata {
                      title
                      description
                    }
                  }
                }
              `}
              render={data =>
                <List style={{ height: "64px", paddingTop: "0", paddingBottom: "0" }}>
                  <Link to="/">
                    <ListItem button className={classes.title} key={"title"}>
                      <Typography variant="h5" noWrap style={{ color: "white", fontFamily: "Staatliches" }}>
                        {data.site.siteMetadata.title}
                      </Typography>
                    </ListItem>
                  </Link>
                </List>
              }
            />
            <IconButton
              color="inherit"
              aria-label="open drawer"
              edge="end"
              style={{ marginLeft: "auto" }}
              onClick={handleDrawerOpen}
              className={clsx(open && classes.hide)}
            >
              <MenuIcon />
            </IconButton>
          </Toolbar>
        </AppBar>
      </HideOnScroll>
      <main
        className={clsx(classes.content, {
          [classes.contentShift]: open,
        })}
      >
        <Container maxWidth="xl">
          {children}
        </Container>
      </main>
      <Hidden mdUp>
        <Drawer
          className={classes.drawer}
          variant="temporary"
          anchor="right"
          open={open}
          onClick={handleDrawerClose}
          classes={{
            paper: classes.drawerPaper,
          }}
        >
          {drawer}
        </Drawer>
      </Hidden>
      <Hidden smDown>
        <Drawer
          className={classes.drawer}
          variant="persistent"
          anchor="right"
          open={open}
          classes={{
            paper: classes.drawerPaper,
          }}
        >
          {drawer}
        </Drawer>
      </Hidden>
    </div >
  );
}

最初にヘッダーのスクロール時の挙動指定とこのページで使うスタイルを定義している。
useStylesを定義してそれを参照する形だ。
Content内でdrawerの構成を書いて、return部でヘッダーと一緒に返している。
その際、Hiddenタグで画面幅によってドロワーの種類を変えている。
見通し悪すぎるんでそのうちファイル分割する予定。

というわけでブログデザインに使っている2つのライブラリの紹介だった。
もしこのデザインをそのまま使いたい人がいれば、Githubからクローンすれば使える。
もちろんバックエンドの構造もそのままじゃないと動かないが…

そしてついに次回からはブログを公開する段階に入っていく。
まずはStrapiを外部からアクセス出来る形にしていこう。

次の記事:Gatsby+Strapiでブログを構築した話(7) Strapiデプロイ with minikube の準備