BEMAロゴ

エンジニアの
成長を支援する
技術メディア

Flutterで『同じdp指定』なのにズレる理由と、flutter_screenutilで実現する大画面でも崩れないUI設計

はじめに

皆さん、こんにちは。株式会社メンバーズ クロスアプリケーションカンパニーOpen in new tabの三宅です。
私は案件業務で、Flutterアプリを55インチのディスプレイにデプロイする必要がありましたが、本番直前まで35インチのディスプレイのみで検証を行うという課題がありました。

本記事では、私が実際に担当した案件業務の経験をもとに、サイズのズレが発生する原因と、どんなサイズのデバイスでもデザイン崩れを防ぐ方法について、わかりやすく解説します。

「Flutterで『同じdp指定』なのにズレる理由と、flutter_screenutilで実現する大画面でも崩れないデザイン術」

Flutterでアプリを開発していると、同じdp指定でもデバイスによって見た目のサイズが微妙に異なることに気づいたことはありませんか?

同じdp指定なのにズレる理由

まずは、Flutterのサイズ指定単位であるdp(density-independent pixel)の仕組みから解説します。dp(Density-independent Pixel)とは、異なるDPI(画面密度)でも見た目のサイズを保つための単位です。つまり、画面の物理サイズ(インチ数)が同じであれば、DPIが異なっても同じ視覚的サイズになるよう設計されています。

DPとは

論理的なピクセル単位。画面密度に左右されず、どの端末でも同じ見た目のサイズを保つための単位。

DPIとは

1インチ(約2.54cm)あたりに表示または印刷されるドットの数を表す単位です。

DPRとは

devicePixelRatio(DPR)は、1論理ピクセルに対応する物理ピクセル数の倍率を示す指標です。画面密度の違いを吸収して、UIを統一的に表示するために使われます。

それでもなぜズレるのか?

Flutterでは、論理ピクセル(dp)をdevicePixelRatio(DPR)でスケーリングして、物理ピクセルに変換します。

変換式:

実際のピクセル数 = dp × (DPI / 160)   
                = dp × devicePixelRatio

例:

10dp × (360 / 160) = 22.5px
  • 22.5px は、物理ピクセルとして表現できないため
  • 通常は 四捨五入23px に変換されます

この「小数点の丸め」が原因で、デバイスごとに1〜2ピクセルの誤差が生まれるのです。

この「小数点の丸め」によって、デバイスごとに1〜2ピクセルの誤差が生じます。
本案件では、この1〜2ピクセルの誤差が55インチの大画面に反映された際、見た目に大きなズレとなって現れてしまいました。

大画面でも崩れないデザイン術

そもそも「デザインが同じ」とは?

今回の案件では、Flutterで「見た目のスケールを合わせる」だけでなく、「体験として同じ」デザインを再現することが求められていました。

具体的には、35インチの練習用ディスプレイでクライアントと合意形成を進めつつ、55インチの本番用ディスプレイでも同様のデザイン体験を実現する必要がありました。

求められていたのは、視覚的・知覚的に以下の点が一致していることです。

  • 同じサイズ感
  • 同じ配置と余白
  • 同じ密度感
  • 同じ操作性(タップしやすさなど)
  • 同じ情報の伝わり方(強調・階層構造)

つまり、「px単位の一致」ではなく、ユーザーがどのデバイスでも同じ印象や体験を持てるかどうかを、本記事における「デザインが同じ」の定義とします。

でも…実際のFlutter開発では?

  • 大きな画面向けに作ったレイアウトが、スマホでははみ出す・潰れる・小さすぎる
  • 小さな画面でぴったりでも、大画面だとスカスカに見える

これらは「画面サイズや解像度」によって、UIのスケーリングが適切に行われていないことが原因です。

なぜ MediaQuery を採用しなかったのか?

画面幅などを取得して動的にサイズを指定することは、Flutterにおけるスタンダードなプラクティスとされています。
しかし本案件では、デザイナーが作成したデザインを忠実に再現することが求められていました。そのため、以下の理由から MediaQueryのみでレスポンシブ対応を完結させるのは現実的ではありませんでした。

すべてのサイズを比率に変換する必要がある

MediaQueryを用いる場合、デザイン上のピクセル(px)指定を画面サイズの比率に換算する必要があります。

// 横幅の30%を指定
MediaQuery.of(context).size.width * 0.3

一見シンプルに見えるものの、実際の開発では Figmaで指定されたpxをすべて「何%か?」に手動で換算する必要があります。
たとえば、width、 height、 padding、 marginなど、あらゆる数値を比率に変換しなければなりません。

さらに、画面サイズのバリエーションが増えるほど計算が複雑になり、設定ミスや修正の手間も増加します。このように、一貫したスケーリングを手動で行うのは現実的ではありませんでした。

一部のWidget変更が、他のWidgetの見た目に影響する可能性がある

MediaQueryベースの設計では、各Widgetが個別に比率を計算してサイズを決定するため、
一部のWidgetを修正しただけでも、全体のバランスや見た目に影響を及ぼす可能性があります

その結果、UI全体の統一感が損なわれやすくなり、レイアウトの調整に手間がかかることも少なくありません

これは特に、複数人で開発を行うプロジェクトや、後から仕様変更が入りやすいアプリにおいて、保守性や拡張性を大きく損なうリスクがある点が大きなデメリットとなると考えました。

フォントサイズや角丸(radius)のスケーリングが難しい

MediaQuery は主に 幅や高さのスケーリング に適した仕組みですが、
実際のUI実装では フォントサイズ角丸(BorderRadius) といった要素についても、画面サイズに応じたスケーリングが求められる場面が多くあります。技術的には、以下のように MediaQuery を使って対応することも可能です

しかしこの方法では、「見た目にバランスの取れたスケーリング」を毎回手動で計算する必要があり、結果として コードの可読性や保守性が大きく低下します。

特にプロジェクト全体で一貫したスケーリングルールを適用するのが難しくなり、UIの統一感を保つことが困難になります。

Text(
  'Hello, Flutter!',
  style: TextStyle(
    fontSize: MediaQuery.of(context).size.width * 0.04, // 画面幅の4%
  ),
);

導入したライブラリ

Flutterでは、こうした「画面サイズ・密度の違いを吸収して、デザインのスケーリングを自動で調整」してくれるライブラリがあります。

flutter_screenutilとは?

flutter_screenutilOpen in new tab は、デザイン時に想定された画面サイズ(designSize)を基準に、実際のデバイスに合わせてスケーリングしてくれるライブラリです。

このライブラリは、ユーザーが利用するデバイスや画面サイズ、OS、入力方法などに応じて、最適なUIやレイアウトを切り替える設計手法(Adaptive design)を実現する際に、特に効果を発揮します。

挙動としては、

  1. 実際のデバイスの論理サイズを取得
  2. designSizeとの比率(スケール)を計算
  3. 幅・高さ・フォントサイズなどをその比率でスケーリング

これにより、どんなサイズのデバイスでも一貫したデザインを保つことができます。

サンプルコード

import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    // flutter_screenutilの初期化
    // designSize: デザイン時の基準となる画面サイズ(例:FigmaやXDで設計したサイズを指定)
    // minTextAdapt: テキストサイズの自動調整を有効化
    // splitScreenMode: 分割画面モード対応
    // builder: ScreenUtilInitのコンテキスト外でもライブラリを使いたい場合のみ利用
    return ScreenUtilInit(
      designSize: const Size(1080, 1920), // ここをデザイン基準サイズに合わせて設定
      minTextAdapt: true, // テキストサイズも画面サイズに合わせて調整
      splitScreenMode: true, // 分割画面でも正しくスケーリング
      builder: (_, child) {
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          title: 'ScreenUtil Sample',
          // テーマ内でもScreenUtilのスケーリングを利用可能
          theme: ThemeData(
            primarySwatch: Colors.blue,
            textTheme: Typography.englishLike2018
                .apply(fontSizeFactor: 1.sp), // フォントサイズもスケーリング
          ),
          home: child, // childに指定したウィジェットがホーム画面になる
        );
      },
      child: const HomePage(), // アプリのホーム画面
    );
  }
}
class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          Row(
            children: [
              Container(
                width: 540, // 幅をスケーリング
                height: 100, // 高さをスケーリング
                color: Colors.blueAccent,
                child: Center(
                  child: Text(
                    'Hello, Flutter!',
                    style: TextStyle(fontSize: 24.sp), // フォントサイズもスケーリング
                  ),
                ),
              ),
              Container(
                width: 540.w, // 幅をスケーリング
                height: 100.h, // 高さをスケーリング
                color: Colors.orange,
                child: Center(
                  child: Text(
                    'Hello, Flutter!',
                    style: TextStyle(fontSize: 24.sp), // フォントサイズもスケーリング
                  ),
                ),
              ),
            ],
          ),
          Image.asset(
            'assets/icon/hoge.jpeg',
            width: 1080.w,
            height: 1100.h,
            fit: BoxFit.cover,
          ),
          Row(
            children: [
              Container(
                width: 540.w, // 幅をスケーリング
                height: 720.h, // 高さをスケーリング
                color: Colors.red,
                child: Center(
                  child: Text(
                    'Hello, Flutter!',
                    style: TextStyle(fontSize: 24.sp), // フォントサイズもスケーリング
                  ),
                ),
              ),
              Container(
                width: 540.w, // 幅をスケーリング
                height: 720.h, // 高さをスケーリング
                color: Colors.green,
                child: Center(
                  child: Text(
                    'Hello, Flutter!',
                    style: TextStyle(fontSize: 24.sp), // フォントサイズもスケーリング
                  ),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

まとめ

項目

説明

dpはDPI差を吸収する単位

devicePixelRatioを使って密度の違いを補正

数ピクセルのズレが起こる理由

dp × DPR の計算で小数点が生じ、四捨五入されるため

dpは画面サイズの違いまでは吸収しない

大画面・小画面ではUIのスケールが合わない

flutter_screenutilの導入が有効

designSizeに対する比率でスケーリング、異なるデバイスでも同じ見た目を実現

最後に

結果として、大画面ではUIが拡大され、小画面ではUIが縮小されるため、
どの画面でも「見た目」が揃います。
練習用の35インチディスプレイでも、本番用の55インチディスプレイでも、
その差分をflutter_screenutilで吸収し、同じデザイン体験を実現することができました。
みなさんの現場でも同様の課題に向き合うとき、少しでも参考になれば幸いです。

この記事が役に立ったと思ったら、
ぜひ「いいね」とシェアをお願いします!

リンクをコピーXでシェアするfacebookでシェアする

この記事を書いた人

三宅 貴大
三宅 貴大
2024年にメンバーズに新卒入社し、現在はモバイルアプリの開発に従事しています。
詳しく見る
ページトップへ戻る