Создание премиального Glassmorphism Flutter Login

Создание премиального Glassmorphism Flutter Login

FlutterPulse

Эта статья переведена специально для канала FlutterPulse. В этом канале вы найдёте много интересных вещей, связанных с Flutter. Не забывайте подписываться! 🚀

Полный разбор кода для премиального, масштабируемого опыта входа с использованием GetX и адаптивной архитектуры Flutter.

Введение

Разница между функциональным приложением и премиальным опытом часто сводится к экрану входа. Это первое впечатление, и, честно говоря, стандартная Material форма уже не подходит.

Мы изучали потрясающий пользовательский интерфейс входа в Flutter, который служит мастер-классом по современному дизайну, сочетающему популярный эффект Glassmorphism с высокомасштабируемой, адаптивной архитектурой.

Видение: макет UI (Figma)

Дизайн пользовательского интерфейса в Figma от автора


🛠️ Зависимости и настройка

Прежде чем мы напишем хотя бы одну строку кода UI, давайте подготовим нашу среду. Этот проект зависит от нескольких ключевых пакетов для управления состоянием, поддержки SVG и адаптивного масштабирования.

В вашем pubspec.yaml Убедитесь, что у вас есть следующие зависимости:

dependencies:
  flutter:
    sdk: flutter

  ...
  get: ^4.7.2
  flutter_svg: ^2.2.1


...
...
...


flutter:

  ...
  assets:
    - assets/

Выполните:

flutter pub get

🏗️ Основная архитектура: масштабирование и стилизация

Хороший пользовательский интерфейс должен быть адаптивным. Вместо жесткого кодирования значений мы создадим утилитарные классы для размеров и цветов. Это обеспечит красивое масштабирование эффекта Glassmorphism на различных размерах устройств.

1) Конфигурация адаптивного приложения

Этот утилитарный класс вычисляет размеры на основе размера экрана устройства и отступов, позволяя нам использовать относительные единицы (например, проценты) для высоты и ширины, делая интерфейс изначально масштабируемым.

В вашем app_config.dart:

import 'package:flutter/material.dart';

class AppConfig {
  BuildContext context;
  double? _height;
  double? _width;
  double? _heightPadding;
  double? _widthPadding;
  Size? _size;
  double? _textScale;

  AppConfig(this.context) {
    MediaQueryData queryData = MediaQuery.of(context);
    _size = queryData.size;
    _height = _size!.height / 100.0;
    _width = _size!.width / 100.0;
    _heightPadding = _height! - ((queryData.padding.top + queryData.padding.bottom) / 100.0);
    _widthPadding = _width! - (queryData.padding.left + queryData.padding.right) / 100.0;
    _textScale = queryData.textScaleFactor;
  }

  double deviceHeight(double v) {
    return _height! * v;
  }

  double deviceWidth(double v) {
    return _width! * v;
  }

  double rHP(double v) {
    return _heightPadding! * v;
  }

  double rWP(double v) {
    return _widthPadding! * v;
  }

  double textSizeScale(double v) {
    return v * _textScale!;
  }
}

2) Константы цвета

Хранение всех цветов в одном месте делает обновление брендинга тривиальным и значительно улучшает ясность кода.

В вашем color_constants.dart:

import 'package:flutter/material.dart';

class ColorConstants {
  static const Color primaryColor = Color(0xFF0F4479);
  static const Color whiteColor = Color(0xFFFFFFFF);

  static const Color blurColor = Color(0xFFD9D9D9);

  static const Color buttonGradient1 = Color(0xFF92BAE6);
  static const Color buttonGradient2 = Color(0xFFD9EBFF);
}

Многоразовые элементы UI

Для поддержания согласованности дизайна и возможности повторного использования мы инкапсулируем пользовательский вид для текстового поля и кнопки с градиентом.

3) Текстовое поле в стиле Glassmorphism

Это ключевой компонент для стиля Glassmorphism! Обратите внимание на использование ClipRRect и BackdropFilter для применения размытия конкретно к фону текстового поля, а затем полупрозрачного Container для создания эффекта "замерзания".

В вашем common_text_field.dart:

import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:login_ui/app_config.dart';
import 'package:login_ui/color_constants.dart';

class CommonTextField extends StatefulWidget {
  final TextEditingController? controller;
  final String label;
  final String hintText;
  final bool? obscureText;
  final Widget? suffixIcon;
  final GestureTapCallback? suffixIconOnTap;
  final FocusNode? focusNode;
  final bool? isRequired;

  const CommonTextField({
    super.key,
    this.controller,
    required this.label,
    required this.hintText,
    this.obscureText = false,
    this.suffixIcon,
    this.suffixIconOnTap,
    this.focusNode,
    this.isRequired,
  });

  @override
  State<CommonTextField> createState() => _CommonTextFieldState();
}

class _CommonTextFieldState extends State<CommonTextField> {
  @override
  Widget build(BuildContext context) {
    AppConfig appConfig = AppConfig(context);

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: EdgeInsets.symmetric(horizontal: appConfig.rWP(1), vertical: appConfig.rHP(1)),
          child: Row(
            children: [
              Text(
                widget.label,
                style: TextStyle(
                  color: ColorConstants.whiteColor,
                  fontWeight: FontWeight.w600,
                  fontSize: appConfig.textSizeScale(18),
                ),
              ),
              if (widget.isRequired ?? false)
                Text(
                  "*",
                  maxLines: 1,
                  style: TextStyle(
                    color: Colors.redAccent,
                    fontSize: appConfig.textSizeScale(18),
                    fontWeight: FontWeight.w900,
                  ),
                ),
            ],
          ),
        ),
        ClipRRect(
          borderRadius: BorderRadius.circular(15),
          child: BackdropFilter(
            filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
            child: Container(
              decoration: BoxDecoration(
                color: ColorConstants.blurColor.withAlpha(30),
                borderRadius: BorderRadius.circular(15),
                border: Border.all(color: ColorConstants.whiteColor.withAlpha(100)),
              ),
              child: TextFormField(
                focusNode: widget.focusNode,
                cursorColor: ColorConstants.whiteColor,
                style: TextStyle(fontSize: appConfig.textSizeScale(14), color: ColorConstants.whiteColor),
                controller: widget.controller,
                obscureText: widget.obscureText ?? false,
                decoration: InputDecoration(
                  border: const OutlineInputBorder(borderSide: BorderSide.none),
                  hintText: widget.hintText,
                  hintStyle: TextStyle(
                    fontSize: appConfig.textSizeScale(14),
                    color: ColorConstants.whiteColor.withOpacity(0.7),
                  ),
                  suffixIcon: IconButton(
                    visualDensity: VisualDensity.compact,
                    onPressed: widget.suffixIconOnTap,
                    icon: widget.suffixIcon ?? SizedBox.shrink(),
                  ),
                  suffixIconConstraints: BoxConstraints(minHeight: 24, minWidth: 24),
                ),
              ),
            ),
          ),
        ),
      ],
    );
  }
}

4) Общая кнопка с градиентом

Эта кнопка использует горизонтальный линейный градиент для современного, слегка металлического вида.

В вашем button_widget.dart:

import 'package:flutter/material.dart';
import 'package:login_ui/app_config.dart';
import 'package:login_ui/color_constants.dart';

class ButtonWidget extends StatelessWidget {
  final GestureTapCallback? onPressed;
  final String? title;

  const ButtonWidget({super.key, this.onPressed, this.title});

  @override
  Widget build(BuildContext context) {
    AppConfig appConfig = AppConfig(context);

    return GestureDetector(
      onTap: onPressed,
      child: Container(
        height: appConfig.deviceHeight(6.5),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(32),
          gradient: LinearGradient(
            colors: [ColorConstants.buttonGradient1, ColorConstants.buttonGradient2, ColorConstants.buttonGradient1],
          ),
        ),
        child: Center(
          child: Text(
            "$title",
            style: TextStyle(
              color: ColorConstants.primaryColor,
              fontSize: appConfig.textSizeScale(24),
              fontWeight: FontWeight.bold,
            ),
          ),
        ),
      ),
    );
  }
}

💻 Основное приложение и контроллер

Конечно, мы начинаем с main.dart.

В этом файле я установил "DeviceOrientation" в Portrait Up, потому что мы не хотим иметь дело с тем беспорядком, который возникает при изменении ориентации устройства.

В классе MyApp я объявил некоторые правила темизации и стиль системного оверлея.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:login_ui/color_constants.dart';
import 'package:login_ui/login_page.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);

  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter Login UI',
      theme: ThemeData(useMaterial3: true, primaryColor: ColorConstants.primaryColor),
      builder: (context, child) {
        return AnnotatedRegion<SystemUiOverlayStyle>(
          value: SystemUiOverlayStyle(
            /// Status Bar
            statusBarColor: Colors.transparent,
            statusBarIconBrightness: Brightness.light,

            /// Bottom Navigation Bar
            systemNavigationBarColor: ColorConstants.whiteColor,
            systemNavigationBarIconBrightness: Brightness.dark,
          ),
          child: child!,
        );
      },
      home: const LoginPage(),
    );
  }
}

Логика с GetX Controller

Контроллер хранит состояние нашей формы (текстовые контроллеры, фокусные узлы, флаги ошибок и видимость пароля) и логику для их обновления.

В вашем login_page_controller.dart:

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

class LoginPageController extends GetxController {
  //TODO: Implement LoginPageController

  TextEditingController emailController = TextEditingController();
  TextEditingController passwordController = TextEditingController();

  final emailFocusNode = FocusNode();
  final passwordFocusNode = FocusNode();

  bool obscurePwdText = true;

  void togglePasswordVisibility() {
    obscurePwdText = !obscurePwdText;
    update();
  }

  bool showEmailError = false;
  bool showPasswordError = false;
}

🖼️ Интерфейс входа: оживляем Glassmorphism

Здесь все элементы собираются вместе. Весь экран обернут в Container с фоновым изображением. Карточка входа и плавающий логотип размещены в Stack для управления их перекрывающимися позициями.

Рецепт Glassmorphism:

  1. Фон сначала: Container с DecorationImage обеспечивает фон для размытия.
  2. Определите форму: ClipRRect гарантирует, что эффект размытия ограничен гладким прямоугольником с закругленными углами.
  3. Примените размытие: Виджет BackdropFilter используется с ImageFilter.blur(sigmaX: 5, sigmaY: 5) для создания эффекта.
  4. Добавьте матовую отделку: Последующий Container обеспечивает слегка прозрачный цвет (ColorConstants.blurColor.withAlpha(30)) и тонкую белую границу, завершая матовую отделку.
  5. Поднимите элемент: Мы используем виджет Positioned с отрицательным значением top чтобы логотип Flutter казался "плавающим" частично над основной карточкой.

В вашем login_page.dart:

import 'dart:developer';
import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart';
import 'package:login_ui/app_config.dart';
import 'package:login_ui/button_widget.dart';
import 'package:login_ui/color_constants.dart';
import 'package:login_ui/common_text_field.dart';
import 'package:login_ui/login_page_controller.dart';

class LoginPage extends StatefulWidget {
  const LoginPage({super.key});

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  @override
  Widget build(BuildContext context) {
    AppConfig appConfig = AppConfig(context);

    return Scaffold(
      body: GetBuilder<LoginPageController>(
        init: LoginPageController(),
        initState: (_) {},
        builder: (controller) {
          return SingleChildScrollView(
            child: Container(
              height: MediaQuery.of(context).size.height,
              width: MediaQuery.of(context).size.width,
              decoration: BoxDecoration(
                image: DecorationImage(image: AssetImage("assets/login_bg.png"), fit: BoxFit.cover),
              ),
              child: Stack(
                alignment: Alignment.bottomCenter,
                children: [
                  Padding(
                    padding: EdgeInsets.symmetric(horizontal: appConfig.rWP(6)),
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        Stack(
                          clipBehavior: Clip.none,
                          alignment: Alignment.topCenter,
                          children: [
                            /// Login
                            ClipRRect(
                              borderRadius: BorderRadius.circular(30),
                              child: BackdropFilter(
                                filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
                                child: Container(
                                  decoration: BoxDecoration(
                                    color: ColorConstants.blurColor.withAlpha(30),
                                    borderRadius: BorderRadius.circular(30),
                                    border: Border.all(color: ColorConstants.whiteColor.withAlpha(100)),
                                  ),
                                  child: Padding(
                                    padding: EdgeInsets.symmetric(horizontal: appConfig.rHP(4)),
                                    child: Column(
                                      crossAxisAlignment: CrossAxisAlignment.start,
                                      children: [
                                        SizedBox(height: appConfig.deviceHeight(9)),
                                        Text(
                                          "Login",
                                          style: TextStyle(
                                            color: ColorConstants.whiteColor,
                                            fontSize: appConfig.textSizeScale(38),
                                            fontWeight: FontWeight.bold,
                                          ),
                                        ),
                                        SizedBox(height: appConfig.deviceHeight(1.2)),
                                        Text(
                                          "Powering Your Business Operations. Smarter Access Starts Here",
                                          maxLines: 2,
                                          style: TextStyle(
                                            color: ColorConstants.whiteColor,
                                            fontSize: appConfig.textSizeScale(18),
                                          ),
                                        ),
                                        SizedBox(height: appConfig.deviceHeight(5)),
                                        CommonTextField(
                                          label: "Email",
                                          hintText: "Enter email",
                                          controller: controller.emailController,
                                          focusNode: controller.emailFocusNode,
                                        ),
                                        controller.showEmailError
                                            ? Padding(
                                                padding: EdgeInsets.symmetric(
                                                  horizontal: appConfig.rWP(1),
                                                  vertical: appConfig.rHP(1),
                                                ),
                                                child: Text(
                                                  "Email is required",
                                                  style: TextStyle(color: Colors.redAccent),
                                                ),
                                              )
                                            : SizedBox(),
                                        SizedBox(height: appConfig.deviceHeight(1)),
                                        CommonTextField(
                                          label: "Password",
                                          hintText: "Enter password",
                                          controller: controller.passwordController,
                                          focusNode: controller.passwordFocusNode,
                                          suffixIcon: Icon(
                                            controller.obscurePwdText ? Icons.visibility_off : Icons.visibility,
                                            color: ColorConstants.whiteColor,
                                          ),
                                          suffixIconOnTap: () => controller.togglePasswordVisibility(),
                                          obscureText: controller.obscurePwdText,
                                        ),
                                        controller.showPasswordError
                                            ? Padding(
                                                padding: EdgeInsets.symmetric(
                                                  horizontal: appConfig.rWP(1),
                                                  vertical: appConfig.rHP(1),
                                                ),
                                                child: Text(
                                                  "Password is required",
                                                  style: TextStyle(color: Colors.redAccent),
                                                ),
                                              )
                                            : SizedBox(),
                                        SizedBox(height: appConfig.deviceHeight(5)),
                                        ButtonWidget(
                                          onPressed: () {
                                            controller.emailFocusNode.unfocus();
                                            controller.passwordFocusNode.unfocus();

                                            /// Email

                                            if (controller.emailController.text.isEmpty) {
                                              controller.showEmailError = true;
                                              controller.update();
                                            } else {
                                              controller.showEmailError = false;
                                              controller.update();
                                            }

                                            /// Password

                                            if (controller.passwordController.text.isEmpty) {
                                              controller.showPasswordError = true;
                                              controller.update();
                                            } else {
                                              controller.showPasswordError = false;
                                              controller.update();
                                            }

                                            if (controller.showEmailError == false &&
                                                controller.showPasswordError == false) {
                                              log("API CALL");

                                              controller.update();
                                            }
                                          },
                                          title: "Submit >>",
                                        ),
                                        SizedBox(height: appConfig.deviceHeight(9)),
                                      ],
                                    ),
                                  ),
                                ),
                              ),
                            ),

                            /// Flutter Logo
                            Positioned(
                              top: -appConfig.deviceHeight(3.3),
                              child: ClipRRect(
                                borderRadius: BorderRadius.circular(15),
                                child: BackdropFilter(
                                  filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
                                  child: Container(
                                    decoration: BoxDecoration(
                                      color: ColorConstants.blurColor.withAlpha(30),
                                      borderRadius: BorderRadius.circular(15),
                                      border: Border.all(color: ColorConstants.whiteColor.withAlpha(100)),
                                    ),
                                    child: SizedBox(
                                      height: appConfig.deviceHeight(6.6),
                                      width: appConfig.deviceWidth(44),
                                      child: Center(
                                        child: Padding(
                                          padding: const EdgeInsets.all(12.0),
                                          child: SvgPicture.network(
                                            "https://storage.googleapis.com/cms-storage-bucket/flutter-logo.6a07d8a62f4308d2b854.svg",
                                            color: Colors.white,
                                          ),
                                        ),
                                      ),
                                    ),
                                  ),
                                ),
                              ),
                            ),
                          ],
                        ),
                      ],
                    ),
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
}

Заключение: выходя за рамки стандартного

Вы успешно перешли от этапа "функционального приложения" к созданию по-настоящему премиального опыта. Этот дизайн демонстрирует, как сочетание популярного тренда, такого как Glassmorphism, с надежной адаптивной архитектурой может создать потрясающее первое впечатление для любого приложения.

Инкапсулируя логику Glassmorphism в вашем CommonTextField и используя утилиты адаптивности в AppConfig, вы создали дизайн, который не только красивый, но и высоко поддерживаемый и адаптируемый. Вперед, добавьте это в свой следующий проект — ваши пользователи обязательно заметят разницу!

Какие другие тренды UI вы хотели бы реализовать в Flutter? Дайте знать в комментариях!

Report Page