こんにちは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;
}
最初はそれぞれの要素にを縦方向に詰められればいいだけだろと思いました。
そのためカードコンテナーを display:inline-block;
にして vertical-align:top;
を指定しましたが下図の様になっていしまいます。
カードコンテナーを display:inline-block;
とalgin-self:flex-start;
を指定しても同様の結果となります。flex
やinline-block
の仕様上仕方ないです。
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は以下の様にしておきます。
<!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!というボタンを押すことで今回はカードが追加されていくことにします。
同じディレクトリに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);
}
大まかな流れとしては以下の通りです。
それでは細かく解説していきます。
// カード情報を格納
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!」というボタンをクリックするとアニメーションつきでカードが追加されていきます。