Flutter專案實戰(1):通用專案框架搭建

fengMisaka發表於2024-11-18

下面介紹 Flutter 最基本的通用專案框架搭建,同時實現了一個登入介面圖示和登入介面。

先看下效果圖:

Flutter_frame_A.gif


  • 使用ScreenUtilInit自適應介面大小;
  • 使用Stack支援多個子介面在同一個全屏主介面上選擇顯示;
  • 使用 Get 外掛實現介面之間的跳轉和國際化翻譯;
  • 介面都透過Transform實現了滑鼠移動介面;
  • 使用Controller.dart管理所有全域性變數和介面控制器;

一、專案目錄結構

Flutter_frame_A.png


  • asserts\images:存放圖片資原始檔的目錄;
  • Translation.dart:翻譯檔案;
  • Controller.dart:全域性變數和對應控制器的定義;
  • LogoWidget.dart:入口圖示介面;
  • LoginWidget.dart:登入介面;
  • main.dart:主介面;

二、程式碼實現與分析

2.1 pubspec.yaml

pubspec.yaml的內容如下:

dependencies:
  flutter:
    sdk: flutter
  flutter_screenutil: ^5.9.3
  get: ^4.6.5

flutter:
  assets:
    - assets/images/

因為用到了 Get 外掛與 ScreenUtilInit,所以需要加上這兩種的依賴;另外定義了圖片資原始檔的路徑;


2.2 Translation.dart

實現了中文簡體、中文繁體和英文的語言切換,翻譯檔案如下所示:

import 'package:get/get.dart';

// (2)自定義自己的國際化字串 
class Translation extends Translations {
  @override
  Map<String, Map<String, String>> get keys => {
    // 1-配置中文簡體
    'zh_CN': {
      '登入': '登入',
      '使用者協議未選中': '使用者協議未選中',
      '請勾選使用者協議': '請勾選使用者協議',
      '使用者名稱異常': '使用者名稱異常',
      '使用者名稱為空': '使用者名稱為空',
      '密碼異常': '密碼異常',
      '密碼為空': '密碼為空',
      '使用者名稱、密碼正確': '使用者名稱、密碼正確',
      '去登陸': '去登陸',
      '使用者': '使用者',
      '密碼': '密碼',
      '同意': '同意',
      '<服務協議>': '<服務協議>',
      '<隱私政策>': '<隱私政策>',
    },

    // 2-配置中文繁體
    'zh_HK': {
      '登入': '登入',
      '使用者協議未選中': '使用者協議未選中',
      '請勾選使用者協議': '請勾選使用者協議',
      '使用者名稱異常': '使用者名稱異常',
      '使用者名稱為空': '使用者名稱為空',
      '密碼異常': '密碼異常',
      '密碼為空': '密碼為空',
      '使用者名稱、密碼正確': '使用者名稱、密碼正確',
      '去登陸': '去登陸',
      '使用者': '使用者',
      '密碼': '密碼',
      '同意': '同意',
      '<服務協議>': '<服務協議>',
      '<隱私政策>': '<隱私政策>',
    },
    
    // 3-配置英文
    'en_US': {
      '登入': 'Login',
      '使用者協議未選中': 'User agreement not selected',
      '請勾選使用者協議': 'Please check the user agreement',      
      '使用者名稱異常': 'Abnormal username',      
      '使用者名稱為空': 'The username is empty',      
      '密碼異常': 'Password exception',      
      '密碼為空': 'Password is empty',      
      '使用者名稱、密碼正確': 'The username and password are correct',      
      '去登陸': 'Go log in',      
      '使用者': 'user',      
      '密碼': 'password',      
      '同意': 'agree with',      
      '<服務協議>': '<Service Agreement>',      
      '<隱私政策>': '<Privacy Policy>',      
    }
  };
}

這裡使用了 Get 外掛方式實現國際化翻譯,具體可參考:Flutter外掛Get(7):實現語言的國際化 - fengMisaka - 部落格園


2.3 Controller.dart

全域性變數和對應控制器的定義:

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'LogoWidget.dart';
import 'LoginWidget.dart';

// state只專注資料,需要使用資料,直接透過state獲取
// logic只專注於觸發事件互動,操作或更新資料
// view只專注UI顯示

// 全域性狀態
class GlobalState {
  final screenSize = const Size(1920,1080).obs; // 螢幕尺寸
  var language = const Locale('zh', 'CN').obs; //語言引數
}

// 全域性變數控制器
class GlobalController extends GetxController {
  // 全域性變數, 內部呼叫
  final GlobalState _globalState = GlobalState();

  // 獲取螢幕尺寸與設定螢幕尺寸的函式
  Size get screenSize => _globalState.screenSize.value;
  set screenSize(Size value) => _globalState.screenSize.value = value;  

  // 獲取當前語言與設定當前語言的函式
  Locale get language => _globalState.language.value;
  set language(Locale language) => () {
        _globalState.language.value = language;
        Get.updateLocale(language);
      }();
}

// 定義全域性變數控制器
final GlobalController globalCtrl = Get.put(GlobalController());

// 初始化通用配置
void initCommomCfg() {
  Get.lazyPut<LoginController>(() => LoginController());
  Get.lazyPut<LogoControl>(() => LogoControl());
}

2.4 LogoWidget.dart

入口圖示介面:

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'LoginWidget.dart';

// 狀態類
class LogoState {
  final _offset = Offset.zero.obs; // 平移距離
  final _isVisable = true.obs; // 是否顯示的變數
  final _x = 50.0.obs; // 水平方向的邊距
  final _y = 20.0.obs; // 垂直方向的邊距
}

// 控制器類
class LogoControl extends LogoState {
  final _state = LogoState();

  // 控制函式實現,以下類似
  Offset get offset => _state._offset.value;
  set offset(Offset value) => _state._offset.value = value;

  bool get isVisable => _state._isVisable.value;
  set setVisable(bool val) => _state._isVisable.value = val;

  double get x => _state._x.value;
  set x(double value) => _state._x.value = value;

  double get y => _state._y.value;
  set y(double value) => _state._y.value = value;
}

class LogoWidget extends StatelessWidget {
  LogoWidget({super.key});

  // 實現控制器
  final LogoControl logoControl = Get.find<LogoControl>();
  final LoginController loginControl = Get.find<LoginController>();

  @override
  Widget build(BuildContext context) {
    return Positioned (
      right: logoControl.x.w,
      top: logoControl.y.h,
      child: Obx(() => Transform.translate ( // 讓部件在 x、y 軸上平移指定的距離
        offset: logoControl.offset, // 平移距離
        child: GestureDetector( // 手勢識別元件
          behavior: HitTestBehavior.opaque,
          child: Visibility( // 是一個用於根據布林值條件顯示或隱藏小部件的控制元件
            visible: loginControl.isHidden(),
            maintainState: true,
            child: MouseRegion( // 以讓滑鼠移動到該元件上時游標為"選中樣式"
                cursor: SystemMouseCursors.click, // 游標為"選中樣式"
                child: IconButton(
                  mouseCursor: SystemMouseCursors.click,
                  onPressed: null,
                  iconSize: 45.w,
                  icon: Image.asset("assets/images/btn_logo.png"), // 顯示圖示
                ),
            ),   
          ),
          // 按壓拖動回撥,以支援滑鼠移動介面
          onPanUpdate: (details) {
              // 透過修改平移距離變數來移動介面
              logoControl.offset += details.delta;
          },
          // 點選事件回撥
          onTap: () {
              // 顯示登入介面
              loginControl.show();
          },             
        ),
      ))
    );
  }
}

2.5 LoginWidget.dart

登入介面:

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'Controller.dart';

// 狀態類
class LoginState {
  final _isHidden = true.obs; // 是否隱藏
  final _width = 400.0.obs; // 寬度
  final _height = 280.0.obs; // 高度
  final _offset = const Offset(0, 0).obs; // 位置
  final _isLogined = false.obs;     // 是否登陸完成

  final _x = 0.0.obs; // 水平方向的邊距
  final _y = 0.0.obs; // 垂直方向的邊距
}

// 控制器類
class LoginController extends GetxController {
  final LoginState state = LoginState();
  
  double get width => state._width.value;
  set width(double value) => state._width.value = value;
  
  double get height => state._height.value;
  set height(double value) => state._height.value = value;

  Offset get offset => state._offset.value;
  set offset(Offset value) => state._offset.value = value;
  
  bool get isLogined => state._isLogined.value;
  set isLogined(bool flag) => state._isLogined.value = flag;

  double get x => state._x.value;
  set x(double value) => state._x.value = value;

  double get y => state._y.value;
  set y(double value) => state._y.value = value;  

  // 是否隱藏
  bool isHidden() {
    return state._isHidden.value;
  }

  // 顯示
  void show() {
    state._isHidden.value = false;
  }

  // 隱藏
  void hide() {
    state._isHidden.value = true;
  }

  // 設定視窗顯示/隱藏狀態
  void setVisable(bool isVisable)
  {
    state._isHidden.value = !isVisable;
  } 

  // 移動
  void move(double x, double y) {
    state._offset.value = Offset(x, y);
  }  

  // 登陸按鈕點選事件
  login(TextEditingController userNameController,
      TextEditingController passWordController) {
    var userName = userNameController.text;
    var passWord = passWordController.text;

    // 1-使用者協議是否勾選
    if (!isChecked.value) {
      Get.snackbar('使用者協議未選中'.tr, '請勾選使用者協議'.tr, snackPosition: SnackPosition.BOTTOM);
      return;
    }
    // 2-使用者名稱判斷
    if (userName.isEmpty) {
      Get.snackbar('使用者名稱異常'.tr, '使用者名稱為空'.tr, snackPosition: SnackPosition.BOTTOM);
      return;
    }
    // 3-密碼判斷
    if (passWord.isEmpty) {
      Get.snackbar('密碼異常'.tr, '密碼為空'.tr, snackPosition: SnackPosition.BOTTOM);
      return;
    }
    Get.snackbar('使用者名稱、密碼正確'.tr, '去登陸'.tr, snackPosition: SnackPosition.BOTTOM);
  }

  // 使用者協議勾選事件
  var isChecked = false.obs;

  void changeChecked(bool value) {
    isChecked.value = value;
  }
}

// 登陸介面
class LoginWidget extends StatelessWidget {
  LoginWidget({super.key});
  final userNameController = TextEditingController();
  final passWordController = TextEditingController();

  final LoginController controller = Get.find<LoginController>(); // 登入介面控制器  

  @override
  Widget build(BuildContext context) {
    return Positioned (
      left: (globalCtrl.screenSize.width - controller.width)/2,
      top: (globalCtrl.screenSize.height - controller.height)/2,
      
      child: Obx(() => Transform.translate ( // 讓部件在 x、y 軸上平移指定的距離
        offset: controller.offset, // 平移距離
        child: GestureDetector( // 手勢識別元件,以讓滑鼠移動到該元件上時游標為"選中樣式"
          behavior: HitTestBehavior.opaque,          
          child: Visibility( // 是一個用於根據布林值條件顯示或隱藏小部件的控制元件
            visible: !controller.isHidden(), // 控制是否顯示
            maintainState: true,           
            child:Container(
                width: controller.width,
                height: controller.height,
                padding: const EdgeInsets.symmetric(horizontal: 0.0),
                decoration: const BoxDecoration(
                  color: Colors.grey,
                ),                 
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    // 登入介面標題欄
                    LoginTabBar(),
                    // 距離上一個View距離             
                    const SizedBox(height: 12), 
                    // 下方編輯介面
                    buildInputWidget(),
                  ],
                ),
            )
          ),

          // 按壓拖動回撥,以支援滑鼠移動介面
          onPanUpdate: (details) {
              controller.offset += details.delta;
          }          
        )
      )
      )
    );
  }

  // 下方編輯介面
   Widget buildInputWidget() {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16.0), // 兩側邊距
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,        
        children: <Widget>[
          TextField(
            controller: userNameController,
            decoration: InputDecoration(labelText: '使用者'.tr),
            style: const TextStyle(fontSize: 16),
            keyboardType: TextInputType.phone,
          ),
          const SizedBox(height: 12), //距離上一個View距離
          TextField(
            controller: passWordController,
            obscureText: true,
            decoration: InputDecoration(labelText: "密碼".tr),
            style: const TextStyle(fontSize: 16),
          ),
          const SizedBox(height: 12), //距離上一個View距離
          buildPrivacyWidget(), //隱私政策
          const SizedBox(height: 12), //距離上一個View距離
          SizedBox(
            width: controller.width-32,
            child: ElevatedButton(
              child: Text('登入'.tr),
              onPressed: () {
                debugPrint("ElevatedButton Click");
                controller.login(userNameController, passWordController);
              },
            )
          ),
          const SizedBox(height: 12), //距離上一個View距離                   
        ],
      )
    );
  } 

  // 隱私協議勾選框
  Widget buildPrivacyWidget() {
    return Row(
      children: [
        Obx(() => Checkbox(
            value: controller.isChecked.value,
            onChanged: (value) => controller.changeChecked(value!))),
        Text('同意'.tr, style: const TextStyle(fontSize: 14)),
        Text('<服務協議>'.tr,
            style: const TextStyle(fontSize: 14, color: Colors.blue)),
        Text('<隱私政策>'.tr, style: const TextStyle(fontSize: 14, color: Colors.blue))
      ],
    );
  }
}

// 登入介面標題欄
class LoginTabBar extends StatelessWidget {
  LoginTabBar({super.key});

  final LoginController loginCtrl = Get.find<LoginController>();

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 44.h,
      color: const Color.fromARGB(128, 20, 45, 86),
      child: Flex(
        direction: Axis.horizontal,
        children: <Widget>[
          const SizedBox(width: 6),
          SizedBox(
            width: 40.h,
            height: 40.h,
            child: Obx(() => IconButton(
              onPressed: () {
                if ( globalCtrl.language == const Locale('zh', 'CN') )
                {
                    globalCtrl.language = const Locale('zh', 'HK');
                }
                else if ( globalCtrl.language == const Locale('zh', 'HK') )
                {
                    globalCtrl.language = const Locale('en', 'US');
                }                                                  
                else if ( globalCtrl.language == const Locale('en', 'US') )
                {
                    globalCtrl.language = const Locale('zh', 'CN');
                }                                                  
              }, 
              icon: () {
                if(globalCtrl.language ==  const Locale('zh', 'CN'))
                {
                  return Image.asset("assets/images/btn_Chinese_jianti.png", width: 40.w, height: 40.h);
                }
                else if(globalCtrl.language == const Locale('zh', 'HK'))
                {
                  return Image.asset("assets/images/btn_Chinese_fanti.png", width: 40.w, height: 40.h);
                }
                else if(globalCtrl.language == const Locale('en', 'US'))
                {
                  return Image.asset("assets/images/btn_English.png", width: 40.w, height: 40.h);
                }
                else
                {
                  return const Icon(null);
                }
              }(),
              padding: EdgeInsets.zero,
            )),
          ),
          const SizedBox(width: 6),
          Expanded(
            flex: 15,
            child: Text(
              "登入".tr,
              style: const TextStyle(
                color: Colors.white,
                fontSize: 16.0,)
              )
          ),
          SizedBox(
            width: 30.h,
            height: 30.h,
            child: IconButton(
              onPressed: () {
                loginCtrl.hide(); // 隱藏登入介面
              }, 
              icon: Icon(
                Icons.close,
                size: 30.w,
              ),
              padding: EdgeInsets.zero,
            ),
          ),
          const SizedBox(width: 6),
        ],
      ),
    );
  }
}

2.6 main.dart

主介面:

// ignore_for_file: prefer_const_constructors
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'Controller.dart';
import 'Translation.dart';
import 'LogoWidget.dart';
import 'LoginWidget.dart';

// 主函式
main(List<String> args) {
  // 初始化通用配置
  initCommomCfg();

  runApp(MainApp());
}

// 主介面
class MainApp extends StatelessWidget {
  MainApp({super.key});

  // 各介面的例項
  final LoginWidget loginWidget = LoginWidget();
  final LogoWidget logoWidget = LogoWidget();

  @override
  Widget build(BuildContext context) {
    return ScreenUtilInit(
      designSize: Size(1920, 1080),
      builder: (context, child) {
        return GetMaterialApp(
          // 配置GetMaterialApp
          translations: Translation(), // 你的翻譯
          locale: const Locale('zh', 'CN'), // 將會按照此處指定的語言翻譯
          fallbackLocale: const Locale('en', 'US'), // 新增一個回撥語言選項,以備上面指定的語言翻譯不存在
          debugShowCheckedModeBanner: false,
          theme: ThemeData(fontFamily: "Ali"),
          home: Scaffold(
            backgroundColor: Colors.white,
            body: Stack( // 使用Stack以同時選擇顯示多個子介面在同一個主介面中
              alignment: Alignment.center,
              children: <Widget>[
                logoWidget,
                loginWidget,
              ],
            ),
          ),
        );
      },
    );
  }
}

三、程式下載

程式下載:Flutter_Demo/CommonFrame-1 at main · confidentFeng/Flutter_Demo


相關文章