📲

adaptiveなるものを学んでみた

2024/01/24に公開

🥿異なる端末ごとにサイズを調整する

アプリを開発をしているときに、iPhoenとPixel以外に、iPadやAndroidのタブレットに対応した画面を表示する要件がある。この間の面談でアダプティブデザインっていうのか〜ってのを聞きました。
レスポンシブデザインとは違うらしい?

スクラップに公式を日本語に翻訳したテキストを書いてます。
https://zenn.dev/joo_hashi/scraps/fd5cdc57bfef12

🔺アダプティブアプリとレスポンシブアプリの違い

アダプティブ・アプリとレスポンシブ・アプリは、アプリの別々の次元として見ることができる。もちろん、アプリは両方であることも、どちらでもないこともあります。

💛レスポンシブ

通常、レスポンシブアプリは、利用可能な画面サイズに合わせてレイアウトを調整します。多くの場合、これは(例えば)ユーザーがウィンドウのサイズを変更したり、デバイスの向きを変えたりした場合に、UIをレイアウトし直すことを意味します。これは、同じアプリが時計、電話、タブレット、ラップトップやデスクトップコンピュータなど、さまざまなデバイスで実行できる場合に特に必要です。

💙アダプティブ

モバイルとデスクトップなど、異なるタイプのデバイスでアプリを実行するには、タッチ入力だけでなく、マウスやキーボード入力にも対応する必要があります。また、アプリの視覚的な密度、コンポーネントの選択方法(カスケードメニューとボトムシートなど)、プラットフォーム固有の機能(トップレベルウィンドウなど)の使用などについて、異なる期待があることを意味します。
詳しくは、次の5分間のビデオをご覧ください:
https://www.youtube.com/watch?v=HD5gYnspYzk

😱早速やってみたけど公式のコードが古い!

ソースコードは僕が少しいじって別のコードになってます。

null saftyに対応していないコードだったのと、Flutter WebとMac OSで表示できなかったので、iPhone、Pixel、iPad Air, Androidのタブレットでで実験してみました。

まずは、モデルを作りましょう。ダミーのデータとして使うMap型のListを用意して、これをmapメソッドで、Map<String, dynamic>のままなので、JSONと同じ{"":""}な構造なので、

モデル
// APIから取得したデータを格納するクラス
class Photo {
  final String title;
  final String url;
  final String thumbnailUrl;

  const Photo({required this.title, required this.url, required this.thumbnailUrl});

  factory Photo.fromJson(Map<String, dynamic> json) {
    return Photo(
        title: json['title'] as String,
        url: json['url'] as String,
        thumbnailUrl: json['thumbnailUrl'] as String);
  }
}

// Map型の_photoをList型のphotosに変換
final List<Photo> photos = _photos
    .map((e) => Photo.fromJson(e))
    .toList(growable: false);

// urlが小さい画像、thumbnailUrlが大きい画像
final List<Map<String, dynamic>> _photos = [
  {
    "albumId": 1,
    "id": 1,
    "title": "accusamus beatae ad facilis cum similique qui sunt",
    "url": "https://via.placeholder.com/600/92c952",
    "thumbnailUrl": "https://via.placeholder.com/150/92c952"
  },
  {
    "albumId": 1,
    "id": 2,
    "title": "reprehenderit est deserunt velit ipsam",
    "url": "https://via.placeholder.com/600/771796",
    "thumbnailUrl": "https://via.placeholder.com/150/771796"
  },
  {
    "albumId": 1,
    "id": 3,
    "title": "officia porro iure quia iusto qui ipsa ut modi",
    "url": "https://via.placeholder.com/600/24f355",
    "thumbnailUrl": "https://via.placeholder.com/150/24f355"
  },
  {
    "albumId": 1,
    "id": 4,
    "title": "culpa odio esse rerum omnis laboriosam voluptate repudiandae",
    "url": "https://via.placeholder.com/600/d32776",
    "thumbnailUrl": "https://via.placeholder.com/150/d32776"
  },
  {
    "albumId": 1,
    "id": 5,
    "title": "natus nisi omnis corporis facere molestiae rerum in",
    "url": "https://via.placeholder.com/600/f66b97",
    "thumbnailUrl": "https://via.placeholder.com/150/f66b97"
  },
  {
    "albumId": 1,
    "id": 6,
    "title": "accusamus ea aliquid et amet sequi nemo",
    "url": "https://via.placeholder.com/600/56a8c2",
    "thumbnailUrl": "https://via.placeholder.com/150/56a8c2"
  },
  {
    "albumId": 1,
    "id": 7,
    "title": "officia delectus consequatur vero aut veniam explicabo molestias",
    "url": "https://via.placeholder.com/600/b0f7cc",
    "thumbnailUrl": "https://via.placeholder.com/150/b0f7cc"
  },
];

端末のサイズが、600未満ならスマートフォンのWidgetを表示、600以上ならタブレットのWidgetを表示を表示する。ListをonTapすると、詳細画面へ遷移する。端末サイズがタブレットサイズの時は、ばつ印のエラーが出てくるので、nullのときは、背景をグレーにする分岐処理を書く必要があった💦

UI
import 'package:adaptive/people.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key}) : super(key: key);

  
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('adaptive_layout'),
      ),
      body: LayoutBuilder(
        builder: (context, constraints) {
          // 画面の幅が600以上なら、WideLayoutを表示する
          if (constraints.maxWidth > 600) {
            return const WideLayout();
          } else {
          /*
          iPhoneとAndroidのスマートフォンの場合は、NarrowLayoutを表示する
          */
            return const NarrowLayout();
          }
        },
      ),
    );
  }
}

// 画面の幅が600以上の場合に表示する
class WideLayout extends StatefulWidget {
  const WideLayout({Key? key}) : super(key: key);

  
  _WideLayoutState createState() => _WideLayoutState();
}

class _WideLayoutState extends State<WideLayout> {
  Photo? _photo;

  
  Widget build(BuildContext context) {
    return Row(
      children: [
        // 人物一覧
        Expanded(
          flex: 2,
          child: PhotoList(
              onPersonTap: (photo) => setState(() => _photo = photo)),
        ),
        // nullの時は、グレーのコンテナを表示する
        Expanded(
          flex: 3,
          child: _photo == null
              ? Container(
                  color: Colors.grey[200],
                )
              : PhotoDetail(_photo!),
        ),
      ],
    );
  }
}

// 画面の幅が600未満の場合に表示する
class NarrowLayout extends StatelessWidget {
  const NarrowLayout({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return PhotoList(
      onPersonTap: (person) => Navigator.of(context).push(
        MaterialPageRoute(
          builder: (context) => Scaffold(
            appBar: AppBar(),
            body: PhotoDetail(person),
          ),
        ),
      ),
    );
  }
}

// JSONPlaceholderから取得したデータを表示する
class PhotoList extends StatelessWidget {
  final void Function(Photo) onPersonTap;

  const PhotoList({Key? key, required this.onPersonTap}) : super(key: key);

  
  Widget build(BuildContext context) {
    return ListView(
      children: <Widget>[
        for (var photo in photos)
          ListTile(
            leading: Image.network(
              photo.thumbnailUrl,
              fit: BoxFit.cover,
              errorBuilder: (context, error, stackTrace) {
                print('Error: $error');
                print('Stack Trace: $stackTrace');
                return const Text('画像のロードに失敗しました');
              },
            ),
            title: Text(photo.title),
            onTap: () => onPersonTap(photo),
          ),
      ],
    );
  }
}

// onTapすると表示される画面
class PhotoDetail extends StatelessWidget {
  final Photo photo;

  const PhotoDetail(this.photo, {Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Center(
      // iPhoneだと横向きにした時に、Overflowしてしまうので、SingleChildScrollViewで囲む
      child: SingleChildScrollView(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(photo.title),
            Image.network(
              photo.url,
              fit: BoxFit.cover,
              errorBuilder: (context, error, stackTrace) {
                print('Error: $error');
                print('Stack Trace: $stackTrace');
                return const Text('画像のロードに失敗しました');
              },
            ),
          ],
        ),
      ),
    );
  }
}

iOS

iPhoneは、横向きにすると、Overflowが発生したので、SingleChildScrollViewでラップする必要があった。Androidはなぜか縦向きのままになる???

iPhoneでビルドした場合:

iPadでビルドした場合:


Android

なぜか、Androidだけ設定してないのに、縦向きに固定されている???

Pixel:

タブレット:


まとめ

レスポンシブデザインは、聞いたことあったけどアダプティブデザインなるものは聞いたことがなかった🫠
なので、長い〜文章を読んだり動画を見ている。オリジナルのものを作り出すと簡単にできないだろうけど、端末ごとに、画面サイズやキーボード使えるのかって設定にはこちらの方が良いらしいことはわかった。ネイティブだったらもっと設定が難しかったような...

Discussion