ピンタレストみたいなカードスタイルで動的に要素を追加する方法
技術スタック HTMLCSSJavascript

ピンタレストみたいなカードスタイルで動的に要素を追加する方法

2021.07.14

こんにちはjunです。突然ですがPinterest(ピンタレスト)ってご存知ですが、以下のような感じのスタイルで画像や動画が一覧で検索できるサービスです。

よくある一覧系のレイアウトは基本的に列と高さを揃えた規則的な物が多い中、ピンタレストは高さが変則であり縦方向に詰めるレイアウトになっています。

ごちゃごちゃとした感じですが、コンテンツがいっぱいあって無駄がないスタイルが好きです。私が関わったあるプロジェクトでこのデザインをやろうとしましたがそこそこ難しかったです。バックから情報を取ってくる仕様だったため、動的に要素を追加してくことを前提として実装の解説をしていきます。コード自体の説明は「実装方法」から行います。

また完成したデモはこちらにあります。

構造

構造は以下の通りとします。

<div class="p-cards-render" id="card-container">
    <div class="c-card-container u-animate">
        <img src="./red.png" class="c-card-img">
    </div>
    <div class="c-card-container u-animate">
        <img src="./red.png" class="c-card-img">
    </div>
    <!-- c-card-containerが基本コンポーネント -->
</div>

card-containerはカード全体の列を定義するコンテナです。c-card-containerは1カードのコンテナーで他のカードとの隙間やレイアウトの定義をしています。そしてカードのコンテナには<img>がラップされ、width:100%; height:100%;がかかっています。今回表示する画像は一面一色に塗り潰された画像です。(background-colorでもよかったのですが)

最終的なcssは以下の通りです。

*{
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

#card-add{
    position: fixed;
    left: 20px;
    top: 50%;
}

.p-main-container{
    max-width: 660px;
    margin: 0 auto;
    padding: 30px 0px;
    min-height: 100vh;
}

.p-cards-render{
    margin: 0 -5px;
    display: flex;
    flex-wrap: wrap;
    position: relative;
}

.p-cards-render .c-card-container{
    position: absolute;
    top: 0px;
    width: 33.3%;
    display: block;
    padding: 0 5px 10px 5px;
}


.c-card-container .c-card-img{
    width: 100%;
    box-shadow: 0px 2px 4px #ccc;
    border-radius: 30px;
    height: 0%;
    display: block;
    opacity: 0;
    transition: all 0.4s;
}
.c-card-container.u-animate .c-card-img{
    height: 100%;
    opacity: 1;
    transition: all 0.4s;
}

表示数が不明な場合CSSだけでは不可能だった

flexでいけんじゃね...?!

最初はそれぞれの要素にを縦方向に詰められればいいだけだろと思いました。

そのためカードコンテナーを display:inline-block; にして vertical-align:top;を指定しましたが下図の様になっていしまいます。

カードコンテナーを display:inline-block;algin-self:flex-start;を指定しても同様の結果となります。flexinline-blockの仕様上仕方ないです。

display gridならいけるが...

flexは縦方向の制御が難しく、ピンタレストの様なスタイルをcssで実現するためにはdisplay:grid;というグリットレイアウトを指定する必要があります。

今回はGridの説明は省きます。グリットレイアウトは各要素の縦方向の位置を定義できるので、柔軟なレイアウトを実現できます。

しかし、グリットレイアウトは列数、行数、各要素の占有列・行を指定する必要があります。そのため動的に要素を追加する場合はJavascriptで各要素の位置スタイルを調整する必要があります。

実装方法

概要

それでは実装の概要について解説します。CSS(インラインスタイル)をカード要素に指定します。いろいろ方法はありますが、ピンタレスト公式でも以下の様なスタイルが要素に当てられています。

<div class="p-cards-render" id="card-container">
    <div class="c-card-container u-animate" id="06p17111s5" style="height:309px; transform:translate(0%,0px);"></div>
    <div class="c-card-container u-animate" id="l1dq533ms9g" style="height:246px; transform:translate(100%,0px);"></div>
    <div class="c-card-container u-animate" id="k6n2ebd918g" style="height:306px; transform:translate(200%,0px);"></div>
    <div class="c-card-container u-animate" id="2j3vvgc373g" style="height:161px; transform:translate(0%,309px);"></div>
    <div class="c-card-container u-animate" id="tagrso5m40o" style="height:255px; transform:translate(100%,246px);"></div>
    <div class="c-card-container u-animate" id="89opa7i1eng" style="height:288px; transform:translate(200%,306px);"></div>
</div>

それぞのコンテナーに対してheight:246px; transform:translate(200%,306px);というものあります。まず固有の高さを指定し、そしてtransformを使用して位置を変更しています。

要素が追加される度に始点(left:0;,top:0;)から移動させ、位置を指定してあげます。2021年7月時点のCSSではこの様にしないと、数不明・ここの高さが不明な要素に対してピンタレストのようなスタイルを適用することができません。

では次はそれを実装するjavascriptを書いていきます。

HTMLの前準備

HTMLは以下の様にしておきます。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"> 
        <title>Pinterest like Style</title>
        <link rel="stylesheet" href="./style.css">
    </head>
    <body>
        <div class="p-main-container">
            <div class="p-cards-render" id="card-container">

            </div>
            <button id='card-add'>ADD!</button>
        </div>
    </body>
    <script src="./card.js" type="text/javascript"></script>
</html>

card-containerにカードが挿入されていきます。ADD!というボタンを押すことで今回はカードが追加されていくことにします。

javascriptはこの通り

同じディレクトリにcard.jsをおいておきます。いきなりですが、全て見せます。完成形は以下の通りです。

// カード情報を格納
let elements = [];

// カードの初期値
let elementInit = {
    id:undefined,
    color:undefined,
    selfHeight:0,
    y:0
};

// 列数
let row = 3;

// 挿入先コンテナ
const cardsContainer = document.getElementById('card-container');

// 追加ボタン
const cardsAddBtn = document.getElementById('card-add');

// カード高さの最大値と最小値
const heightMax = 400;
const heightMin = 100;

// 色のパターンとカード間の隙間(px)
const colors = ['red','yellow','green','orange','blue','purple'];
const cardGap = 10;

// 追加ボタンにイベントリスナーを付与
cardsAddBtn.addEventListener('click',()=>{
    return onClickAdd();
})

// イベントリスナーの内容
let onClickAdd = ()=>{
    let card = createCard();
    insertCard(card.dom);
    scanAdd(card.id);
}

// カードのDOMを新規作成
let createCard = ()=>{
    let info = {...elementInit};

    // カードの要素をdivで作成
    let contain = document.createElement("DIV");
    contain.setAttribute('class','c-card-container');

    // IDをランダムに作成、記録
    let idname = Math.random().toString(32).substring(2);
    contain.setAttribute('id',idname);
    info.id = idname;

    // カードに表示される画像(一色塗り潰し)を設定、記録
    // colorsの値と画像名が同じ。
    let card = document.createElement("IMG");
    let color = colors[Math.floor(Math.random() * colors.length)];
    info.color = color;

    // IMG要素にカラーの画像とクラス名を付与
    card.setAttribute('src','./'+color+'.png');
    card.setAttribute('class','c-card-img');

    // カードに対して不明な高さを与える
    info.selfHeight = Math.floor(Math.random()*(heightMax+1-heightMin))+heightMin;
    
    // カードDIVにIMGを追加
    contain.appendChild(card);

    // elementsに記録。DOMとIDを返す
    elements.push(info);
    return {dom:contain,id:idname};
}

// カードコンテナーにカードを追加する
let insertCard = (cardDom)=>{
    cardsContainer.appendChild(cardDom);
}

// これが大切
// IDで紐づいたDOMに対して高さと位置を決定させる。
let scanAdd = (id) =>{
    // elementsから対象カードのIDの番号、情報を取得
    let index = elements.findIndex(ele=>{return ele.id===id});
    let ele = elements[index]

    // DOMを取得
    let dom = document.getElementById(ele.id);

    // index、つまりカードが何晩目かと列数でx,yの位置を決定する。
    let height = ele.selfHeight
    ele.y = (index < row)?height:elements[index - row].y + height;
    let x = (index%(row)*100) + '%';
    let y = (index < row)?0:elements[index - row].y;
    
    // 位置をずらすスタイルを適用
    dom.setAttribute('style',`height:${height}px; transform:translate(${x},${y}px);`)

    // アニメーション用のスタイルを追加
    setTimeout(()=>{
        dom.classList.add('u-animate')
    },500);
}

大まかな流れとしては以下の通りです。

  1. 列数、カード座標情報などの初期値と格納する配列の定義。
  2. ボタンに対するイベントリスナーの定義、ボタンを押したら以下を発火。
  3. 新しいカードを作成する
  4. カードをコンテナに追加
  5. 追加数、列数に応じて追加したカードの位置を決定

それでは細かく解説していきます。

定数などの準備

// カード情報を格納
let elements = [];

// カードの初期値
let elementInit = {
    id:undefined,
    color:undefined,
    selfHeight:0,
    y:0,
    x:0
};

// 列数
let row = 3;

// 挿入先コンテナ
const cardsContainer = document.getElementById('card-container');

// 追加ボタン
const cardsAddBtn = document.getElementById('card-add');

// カード高さの最大値と最小値
const heightMax = 400;
const heightMin = 100;

// 色のパターンとカード間の隙間(px)
const colors = ['red','yellow','green','orange','blue','purple'];
const cardGap = 10;

ここでは追加する際に必要な定数やDOMを定義しておきます。 elementsには作成したカードコンテンツを記録しておきます。列+1番目以降(今回は4番目以降)の高さを調整するときなどに使用します。

カードの作成関数を設定

// カードのDOMを新規作成
let createCard = ()=>{
    let info = {...elementInit};

    // カードの要素をdivで作成
    let contain = document.createElement("DIV");
    contain.setAttribute('class','c-card-container');

    // IDをランダムに作成、記録
    let idname = Math.random().toString(32).substring(2);
    contain.setAttribute('id',idname);
    info.id = idname;

    // カードに表示される画像(一色塗り潰し)を設定、記録
    // colorsの値と画像名が同じ。
    let card = document.createElement("IMG");
    let color = colors[Math.floor(Math.random() * colors.length)];
    info.color = color;

    // IMG要素にカラーの画像とクラス名を付与
    card.setAttribute('src','./'+color+'.png');
    card.setAttribute('class','c-card-img');

    // カードに対して不明な高さを与える
    info.selfHeight = Math.floor(Math.random()*(heightMax+1-heightMin))+heightMin;
    
    // カードDIVにIMGを追加
    contain.appendChild(card);

    // elementsに記録。DOMとIDを返す
    elements.push(info);
    return {dom:contain,id:idname};
}

カードの作成関数createCard()を作ります。ここはカードの要素を作成して、ランダムなIDを付与します。変数infoにはカードの高さ、IDを記録してelementsに入れておきます。

要素の挿入関数を作成

// カードコンテナーにカードを追加する
let insertCard = (cardDom)=>{
    cardsContainer.appendChild(cardDom);
}

ここは作成したカードのDOMをカードコンテナに挿入します。

挿入されたカードの高さと位置を決定

// これが大切
// IDで紐づいたDOMに対して高さと位置を決定させる。
let scanAdd = (id) =>{
    // elementsから対象カードのIDの番号、情報を取得
    let index = elements.findIndex(ele=>{return ele.id===id});
    let ele = elements[index]

    // DOMを取得
    let dom = document.getElementById(ele.id);

    // index、つまりカードが何晩目かと列数でx,yの位置を決定する。
    let height = ele.selfHeight
    ele.y = (index < row)?height:elements[index - row].y + height;
    let x = (index%(row)*100) + '%';
    let y = (index < row)?0:elements[index - row].y;
    
    // 位置をずらすスタイルを適用
    dom.setAttribute('style',`height:${height}px; transform:translate(${x},${y}px);`)

    // アニメーション用のスタイルを追加
    setTimeout(()=>{
        dom.classList.add('u-animate')
    },500);
}

scanAdd()では対象のID(DOMのID)に基づいてelementsから高さを設定する対象のカードと、そのカードの1行上のカードの情報を取得します。例えば5番目のカードの場合、2番目のカードの高さを用いてY座標を決定します。この関数ではその情報を元にしてカードに高さを与え、また表示する位置のx,y座標を決定してtranslateの値を決定します。

関数の連結とイベントリスナー

// 追加ボタンにイベントリスナーを付与
cardsAddBtn.addEventListener('click',()=>{
    return onClickAdd();
})

// イベントリスナーの内容
let onClickAdd = ()=>{
    let card = createCard();
    insertCard(card.dom);
    scanAdd(card.id);
}

最後に上記の関数を連結し、イベントリスナーのコールバックに設定します。イベントリスナーはカードの追加ボタンに付与されています。

実装後の動き

完成したデモはこちらにあります。「ADD!」というボタンをクリックするとアニメーションつきでカードが追加されていきます。

Copyright © 2021 jun. All rights reserved.