画像をボックスの左下や右下角に配置したレイアウトで、回り込んだテキストと画像を下揃えして表示したい。レスポンシブで! その実現方法のメモです。
※素材画像: Photo by Chris Lawton on Unsplash
画像をfloatにして、テキストと下揃えする位置まで動かせば実現できそうです。
課題は、**下揃えになる位置をどのようにして取得するか?**です。ブラウザで表示してテキストの折り返しの状態を見ながら画像の位置を手作業で調整することによって画像とテキストを下揃えにする位置を見つけることはできますが、これではレスポンシブに対応できません。
そこで、JavaScriptのIntersection Observer APIを使って、下揃えの調整を自動的に行う方法を考えました。
完成したコードはこちら: https://codepen.io/kaz_hashimoto/pen/xxPwzZN
動作を確認した環境(記事執筆時点)
- Chrome 97, Firefox 96, Opera 78, Safari 15.2, Edge 97
- IE11 ただしpolyfill使用: IntersectionObserver polyfill
実現方法
HTMLとCSS
元々のアイデアは、こちらのブログ記事に紹介されている方法(ボックスの幅は固定)を参考にさせていただきました。
[資料1] CSSで左下・右下にある画像を回り込ませたい!
div.photoの前に「つっぱり棒」の役割をするdv.spaceをfloatで配置してテキストを回り込ませ、.spaceのheightを調整することにより.photoを右下(左下)に配置するという基本的な仕組みは、上記ブログ記載の手法と同じです。
[資料1]の方式と違う点は、div.boxでfloatの解除をすぐにしない点です。これは、Intersection Observerにdiv.boxとdiv.photoの交差を検出させるためです。代わりにfloatの解除は.boxの内側と外側の2箇所で行います。
- div.clear-after: .boxの内側。最初は
display: none
にしておき、下揃えの自動調整が完了後display: block
に切り替えてfloatを解除します。これにより.photoが下側にはみ出した部分が解消されます。 - div.clear: .boxの外側。.boxに続く要素がある場合にレイアウト崩れを防止するため
<div class="box">
<div class="space"></div>
<div class="photo"><img src="image.png" width="150" height="150" alt=""></div>
<p>テキスト</p>
<div class="clear-after"></div>
</div>
<div class="clear"></div>
注意点として、ページの最初のロード時にもdiv.boxとdiv.photoの交差を正しく検出させるために、img要素にwidth
, height
属性を指定して画像のアスペクト比をブラウザに認識させる必要があります1。
.box {
border: 1px solid blue;
}
.clear-after {
display: none; /* 交差を検出するため最初はfloat解除しない */
clear: both;
height: 0;
}
.clear {
clear: both; /* 後続要素のレイアウト崩れを防止する */
}
.space {
float: right; /* 画像を左下に配置する場合は値をleftにする */
height: 5em; /* 仮の高さ */
outline: 1px solid red;
}
.photo {
width: 150px;
float: right; /* 画像を左下に配置する場合は値をleftにする */
clear: both;
margin-left: 10px;
}
.photo img {
width: 100%;
height: auto;
vertical-align: top;
}
div.spaceのheight
には仮の高さ(ここでは5em)を与えておき、div.photoとそのコンテナのdiv.boxとの位置関係(下図A, B)によってheight
値を1pxずつ加算・減算することにより、div.photoの最適な縦位置になるまで調整していきます。
ここで厄介なのは、div.photoが完全にコンテナの内側に入っている状態で(下図C)div.spaceのheight
を1pxだけ大きくした時、div.photoの上側に開いた空きにテキストがもう1行引き込まれた結果、テキスト最後の行が上にせり上がってdiv.photoがdiv.boxの下側境界を大きくはみ出してしまうケースです(図D)。
この場合は、div.clear-afterを有効にしてfloatを解除することにより、div.boxがdiv.photoを含んだ高さになるようにdiv.boxの下側境界が調整されるので、はみ出し部分が解消されます。ぴったりテキストの下揃えにはなりませんが、Cの状態よりは見た目がマシでしょう。ただし、div.boxの幅が狭い場合、CもDもdiv.boxとテキスト下側境界との間が大きく開いてしまうことがあります。これはどうしようもないので、その場合もDの状態でfloat解除します。
JavaScript
div.boxを「交差ルート」、div.box > div.photoを交差の監視対象「ターゲット」としてオブザーバーを作成します。本記事のサンプルのコードでは1ページに4個のdiv.boxがあるので、それぞれのboxを指すセレクタとオブザーバーのペアをMapオブジェクトに入れて管理します。
initObservers()
はウィンドウのリサイズ時にも呼ばれます。resize
イベントハンドラーから呼ばれた場合、既に動作中のオブザーバーにはターゲットの監視を停止させ、新たにオブザーバーを再作成して監視を再開します。
const observers = new Map(); // セレクタとオブザーバーのペアを記憶しておくMap
const boxes = ['.b1', '.b2', '.b3', '.b4']; // 適用する対象のboxのセレクタ
initObservers(boxes); // boxごとにオブザーバーを作成する
function initObservers(selectors) {
selectors.forEach(function(s) {
let obs = observers.get(s); // セレクタに対するオブザーバーを取り出す
if (obs) {
obs.disconnect(); // ターゲットの監視を停止
observers.delete(s); // マップから削除
}
obs = createObserver(s); // オブザーバを(再)作成
observers.set(s, obs); // セレクタとオブザーバーのペアを記憶
});
}
交差を検出させるタイミングは次の2箇所で、コールバックhandleIntersect()
が呼ばれます。
- div.photoが1pxでもdiv.boxに交差した時
- div.photoが完全にdiv.boxに収まった時
function createObserver(selector) {
const options = {
root: document.querySelector(selector), // 交差ルート(box)
rootMargin: '0px',
threshold: [0, 1] // ターゲットが1pxでも交差した時と完全にboxに入った時にコールバックを実行
};
const obs = new IntersectionObserver(handleIntersect, options);
const target = options.root.querySelector('.photo');
obs.observe(target); // このbox内のdiv.photoを監視対象とする
return obs;
}
コールバックの処理では、div.photoが1pxでもdiv.boxに交差してた場合は、ターゲットの監視を停止し縦位置の調整を開始します。
div.photoが全く交差していないケース(つまりdiv.boxの下側境界より下に配置されていた場合)では、一旦div.photoをdiv.boxの上辺に移動します。これにより交差が検出されるので、コールバックが再び呼ばれて今度はentry.isIntersecting
がtrue
になり、縦位置の調整処理が実行されます。
function handleIntersect(entries, observer) {
entries.forEach(function(entry) {
if (entry.isIntersecting) { // 画像の一部または全部がboxに交差している
observer.unobserve(entry.target); // このターゲットの監視を停止
process(observer.root, entry.target); // 画像の縦位置の調整を開始
} else { // 画像がboxに全く交差していない
adjustHeight(observer.root, 0, 0); // 画像をboxの上辺に移動し交差を発生させる
}
});
}
交差を検出したdiv.photoについて、交差の方向(前述の図A or B)に従ってdiv.spaceのheight
を1pxずつ動かして最適な位置を探します。1pxを動かす前後でdiv.boxの下側境界を跨いだらループを抜けます。
処理の最後にclearfix()
を呼んでfloatを解除します。
function process(root, target) {
let over = difference(root, target); // 画像とboxの下辺のずれの長さ(px)を取得
if (over > 0) { // 画像がboxの下側にはみ出している
do {
if (!adjustHeight(root, 1)) { // 画像を1px上に戻す
break; // boxの上辺に達したのでループを抜ける
}
over = difference(root, target); // ずれの大きさを測る
} while (over > 0); // まだはみ出している
if (over < 0) { // 画像がboxの内側に戻り過ぎた
adjustHeight(root, -1); // 画像を1px下に戻す(はみ出した状態にする)
}
} else if (over < 0) { // 画像がboxの内側に完全に収まっていて下に隙間がある
do {
adjustHeight(root, -1); // 画像を1px下げる
over = difference(root, target); // ずれの大きさを測る
} while (over < 0); // まだ下に隙間がある
}
clearfix(root); // boxのfloatを解除する(はみ出していたら帳尻を合わせる)
}
(以下の関数の説明はコードのコメント参照)
difference(root, target)
// 画像(target)がbox(root)に対して「下側にはみ出している長さ」(px)を返す。
// 画像がboxに完全に収まっている場合、戻り値は負または0で、その絶対値は
// 画像の下側とboxの下辺との隙間の長さ(px)を表す。
function difference(root, target) {
const rb = root.getBoundingClientRect();
const rt = target.getBoundingClientRect();
return rt.bottom - rb.bottom;
}
clearfix(root)
// boxのfloatを解除する
function clearfix(root) {
const o = root.querySelector('.clear-after');
o.style.display = 'block';
}
adjustHeight(root, disp, force)
// box(root)に対してspaceの高さをdisp(px)分更新する。
// dispが正の場合、spaceのheightは小さくなり、画像が上へ移動する。
// 画像が上辺に達してdisp分移動できない時、heightを0に設定してfalseを返す。
// dispが負の場合、spaceのheightは大きくなり、画像が下へ移動する。
// forceに値が指定された場合、spaceのheightの値をforceに設定する。
//
function adjustHeight(root, disp, force) {
const sp = root.querySelector('.space');
let height;
let status = true;
if (force !== undefined) {
height = force;
} else {
const style = window.getComputedStyle(sp);
height = parseFloat(style.height);
height -= disp;
if (height < 0) {
height = 0;
status = false;
}
}
height = Math.floor(height);
sp.style.height = height + 'px';
return status;
}
load/resizeイベントのハンドラ
let timer = 0;
// IE11用。画像のロードが終わってからも計算し直す
window.addEventListener('load', function() {
layout();
});
window.addEventListener('resize', function() {
if (timer) { // 計算の負荷が高いためlayout()の呼び出しを100msごとに間引く
return;
}
timer = setTimeout(layout, 100);
});
function layout() {
if (timer) {
clearTimeout(timer);
timer = 0;
}
removeClearFix(boxes); // boxのfloat解除を無効に戻す
initObservers(boxes); // オブザーバを再設定する
}
// div.clear-afterに設定したfloat解除を無効にする
function removeClearFix(boxes) {
boxes.forEach(function(sel) {
const root = document.querySelector(sel);
const o = root.querySelector('.clear-after');
o.style.display = 'none';
});
}
残された課題
- div.photoの横のスペースは、テキストの
min-content
幅が最低でも必要(下記の式)。十分なスペースが確保できない場合、折り返されたテキストとの下揃えはできない。画像がテキストの最終行より下に配置される。例)テキストが長い英単語を含む場合
- div.boxの幅 > テキストのmin-content幅 + 画像の幅
- ブラウザの文字サイズのみ拡大した時、レイアウトが追随しない。ページを再読み込みすれば反映される。
-
IE11の場合、これだけでは足りず、loadイベントも捕捉して交差を再計算させました(サンプルのコード参照) ↩