1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js開発者がFlutterに挑戦!レスポンシブデザインで躓いた話と学んだ違い

Posted at

はじめに

普段Next.jsでWeb開発をしている私が、初めてFlutterに挑戦した際の体験談です。特にレスポンシブデザインでタブレットではポップオーバー、スマホではボトムシートを表示しようとした際に遭遇したエラーと、その解決方法について共有します。

Next.jsとFlutterの主な違い

1. アーキテクチャの違い

Next.js(React)

// コンポーネントベース、JSX記法
function MyComponent({ title, children }) {
  return (
    <div className="container">
      <h1>{title}</h1>
      {children}
    </div>
  );
}

Flutter

// Widgetベース、Dart言語
class MyWidget extends StatelessWidget {
  final String title;
  final Widget child;
  
  const MyWidget({Key? key, required this.title, required this.child}) : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    return Container(
      child: Column(
        children: [
          Text(title),
          child,
        ],
      ),
    );
  }
}

2. 状態管理の違い

Next.js(React Hook Form)

import { useForm } from 'react-hook-form';

function MyForm() {
  const { register, handleSubmit, watch } = useForm();
  const watchedValue = watch('inputField');
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('inputField')} />
      <p>現在の値: {watchedValue}</p>
    </form>
  );
}

Flutter(MVVM + Provider + StateNotifier)

FlutterではMVVM(Model-View-ViewModel)パターンを採用しました。これは過去にWPF開発で使用していた設計パターンと同じで、馴染みやすかったです。

// Model - データ構造を定義
class FormModel {
  final String inputValue;
  final bool isLoading;
  final String? errorMessage;
  
  const FormModel({
    required this.inputValue,
    this.isLoading = false,
    this.errorMessage,
  });
  
  FormModel copyWith({
    String? inputValue,
    bool? isLoading,
    String? errorMessage,
  }) {
    return FormModel(
      inputValue: inputValue ?? this.inputValue,
      isLoading: isLoading ?? this.isLoading,
      errorMessage: errorMessage ?? this.errorMessage,
    );
  }
}

// ViewModel - ビジネスロジックと状態管理
class FormViewModel extends StateNotifier<FormModel> {
  FormViewModel() : super(const FormModel(inputValue: ''));
  
  // WPFのINotifyPropertyChangedと同様の役割
  void updateInput(String value) {
    state = state.copyWith(inputValue: value);
  }
  
  Future<void> submitForm() async {
    state = state.copyWith(isLoading: true, errorMessage: null);
    
    try {
      // ビジネスロジック実行
      await _performSubmission(state.inputValue);
      state = state.copyWith(isLoading: false);
    } catch (e) {
      state = state.copyWith(
        isLoading: false, 
        errorMessage: e.toString()
      );
    }
  }
  
  Future<void> _performSubmission(String value) async {
    // 実際の処理...
    await Future.delayed(Duration(seconds: 1));
  }
}

// Provider定義 - WPFのDataContextのような役割
final formViewModelProvider = StateNotifierProvider<FormViewModel, FormModel>(
  (ref) => FormViewModel(),
);

// View - UI描画部分
class MyForm extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final formState = ref.watch(formViewModelProvider);
    final formViewModel = ref.read(formViewModelProvider.notifier);
    
    return Column(
      children: [
        TextField(
          onChanged: formViewModel.updateInput,
        ),
        Text('現在の値: ${formState.inputValue}'),
        if (formState.isLoading)
          CircularProgressIndicator(),
        if (formState.errorMessage != null)
          Text('エラー: ${formState.errorMessage}', 
               style: TextStyle(color: Colors.red)),
        ElevatedButton(
          onPressed: formState.isLoading ? null : formViewModel.submitForm,
          child: Text('送信'),
        ),
      ],
    );
  }
}

WPFとFlutterのMVVM比較

要素 WPF Flutter
Model データクラス Dartのimmutableクラス
View XAMLファイル Widget(build メソッド)
ViewModel INotifyPropertyChanged StateNotifier
DataBinding {Binding Path=Property} ref.watch(provider)
Command ICommand実装 ViewModelのメソッド
DataContext ViewModel設定 Provider

WPF開発経験があったおかげで、Flutter の MVVM パターンはすぐに理解できました。特に状態の変更通知の仕組み(WPFの INotifyPropertyChanged と Flutter の StateNotifier)が非常に似ており、スムーズに移行できました。

Flutter独自の重要な概念

Widget Tree

FlutterはすべてがWidgetで構成されており、React のJSXとは異なり、ネストが深くなりがちです。

// 深いネストの例
Scaffold(
  appBar: AppBar(title: Text('My App')),
  body: Container(
    padding: EdgeInsets.all(16),
    child: Column(
      children: [
        Card(
          child: Padding(
            padding: EdgeInsets.all(8),
            child: Text('Hello World'),
          ),
        ),
      ],
    ),
  ),
)

MediaQuery によるレスポンシブデザイン

Widget build(BuildContext context) {
  final screenWidth = MediaQuery.of(context).size.width;
  final isTablet = screenWidth > 600;
  
  return isTablet ? TabletLayout() : MobileLayout();
}

遭遇したエラーと解決方法

問題:BoxConstraints エラー

レスポンシブデザインを実装中に以下のエラーが発生しました:

BoxConstraints forces an infinite width.
RenderBox was not laid out: RenderConstrainedBox
RenderBox was not laid out: _RenderLayoutBuilder

原因となったコード(問題のあるコード)

// ❌ 問題のあるコード
Widget build(BuildContext context) {
  return Column(
    children: [
      Container(
        width: double.infinity, // 無限の幅を要求
        child: Row(
          children: [
            Expanded(
              child: Container(
                width: double.infinity, // さらに無限の幅を要求
                child: Text('レスポンシブテスト'),
              ),
            ),
          ],
        ),
      ),
    ],
  );
}

解決したコード

// ✅ 修正されたコード
Widget build(BuildContext context) {
  final screenWidth = MediaQuery.of(context).size.width;
  final isTablet = screenWidth > 600;
  
  return Column(
    children: [
      Container(
        width: screenWidth, // 具体的な幅を指定
        child: Row(
          children: [
            Expanded(
              child: Container(
                // width指定を削除、Expandedが自動調整
                child: Text('レスポンシブテスト'),
              ),
            ),
          ],
        ),
      ),
      // レスポンシブな表示
      if (isTablet)
        _buildPopover(context)
      else
        _buildBottomSheet(context),
    ],
  );
}

レスポンシブデザインの実装例

タブレット用ポップオーバー

Widget _buildPopover(BuildContext context) {
  return GestureDetector(
    onTap: () {
      showDialog(
        context: context,
        builder: (context) => Dialog(
          child: Container(
            width: 400,
            height: 300,
            padding: EdgeInsets.all(16),
            child: Column(
              children: [
                Text('タブレット用ポップオーバー'),
                Spacer(),
                ElevatedButton(
                  onPressed: () => Navigator.pop(context),
                  child: Text('閉じる'),
                ),
              ],
            ),
          ),
        ),
      );
    },
    child: Container(
      padding: EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: Colors.blue,
        borderRadius: BorderRadius.circular(8),
      ),
      child: Text('タップしてポップオーバー', style: TextStyle(color: Colors.white)),
    ),
  );
}

スマホ用ボトムシート

Widget _buildBottomSheet(BuildContext context) {
  return GestureDetector(
    onTap: () {
      showModalBottomSheet(
        context: context,
        isScrollControlled: true,
        builder: (context) => Container(
          height: MediaQuery.of(context).size.height * 0.7,
          padding: EdgeInsets.all(16),
          child: Column(
            children: [
              Text('スマホ用ボトムシート'),
              Spacer(),
              ElevatedButton(
                onPressed: () => Navigator.pop(context),
                child: Text('閉じる'),
              ),
            ],
          ),
        ),
      );
    },
    child: Container(
      padding: EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: Colors.green,
        borderRadius: BorderRadius.circular(8),
      ),
      child: Text('タップしてボトムシート', style: TextStyle(color: Colors.white)),
    ),
  );
}

完全なサンプルコード

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'レスポンシブデザイン Demo',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: ResponsiveDemo(),
    );
  }
}

class ResponsiveDemo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final screenWidth = MediaQuery.of(context).size.width;
    final isTablet = screenWidth > 600;
    
    return Scaffold(
      appBar: AppBar(
        title: Text('レスポンシブデザイン'),
      ),
      body: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              isTablet ? 'タブレット表示' : 'スマホ表示',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
            SizedBox(height: 32),
            if (isTablet)
              _buildPopover(context)
            else
              _buildBottomSheet(context),
          ],
        ),
      ),
    );
  }
  
  // ... (上記のメソッドをここに含める)
}

学んだポイント

  1. BoxConstraints の理解が重要

    • double.infinity の使用には注意が必要
    • 親要素との制約関係を理解する
  2. MediaQuery の活用

    • レスポンシブデザインの基本
    • 画面サイズに応じた条件分岐
  3. Widget の制約システム

    • 「制約は下に、サイズは上に、親が位置を決める」の原則
  4. MVVMパターンの活用

    • WPF開発経験が活かせる設計パターン
    • StateNotifierによる状態管理がINotifyPropertyChangedと類似
    • Providerを使った依存性注入でテスタブルなコード構成

まとめ

Next.jsからFlutterへの移行で最も戸惑ったのは、Widget Tree の概念とレイアウト制約システムでした。特に BoxConstraints エラーは初心者が必ず遭遇する問題だと思います。

しかし、一度理解すれば、Flutterの宣言的UIは非常に直感的で、クロスプラットフォーム開発の強力なツールだと感じました。

Web開発者の皆さんも、ぜひFlutterに挑戦してみてください!

参考リンク

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?