Intro


Иногда при внедрении интерфейса недостаточно тех возможностей кастомизации, которые предоставляет Flutter. Подтверждением этому является большое количество вопросов на Stackoverflow, типа, как добавить тень или градиент к какому-нибудь элементу управления (кнопке, текстовому полю и т.д.). Как правило, ответы сводятся к тому, что надо либо использовать элементы управления из сторонних библиотек, либо обернуть элемент управления в Container c необходимым декорированием, либо создать собственный элемент управления. Однако, эти подходы имеют ограничения или требуют много кода. Особенно добавляет работы настройка различного декорирования элементов управления для различных их состояний и анимирование переходов между этими состояниями. В статье я расскажу, как расширить возможности кастомизации этих элементов без создания новых виджетов и без сторонних библиотек.


Если мы посмотрим на настройки стандартных элементов управления Flutter, то заметим, что многие из них имеют параметр shape. Он и предоставит нам возможность нестандартной кастомизации. Этот же параметр shape можно изменять и через настройки темы приложения. Различные отображения элемента управления для разных его состояний и анимация перехода между этими состояниями настраиваются стандартным для элементов управления способом.


В качестве примера я покажу как декорировать кнопку. Но этот же способ подойдёт большинству других элементов управления Flutter. Так как параметр shape для многих элементов управления имеет одинаковый тип, почти всегда достаточно просто перенести значение его настройки из одного элемента управления (например, кнопки) в другой (например, Chip), чтобы добиться схожего отображения.


Для примера предлагаю изменить форму кнопки и добавить градиентную границу. В результате должна выглядеть вот так:


Button example


Весь исходный код приложения доступен на github.


Готовое веб-приложение можно посмотреть в dartpad.


Сначала создадим приложение, которое отображает лишь стандартную кнопку.


Выполним команду:


flutter create fl_decor_example

Заменим содержимое файла main.dart на следующее:


import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
          body: Center(
        child: TextButton(
          child: const Text("Text Button"),
          onPressed: () {},
        ),
      )),
    );
  }
}

Создадим файл shape_decoration.dart, в который поместим класс-заготовку для стилизации элемента.


import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'dart:ui';

class MyShapeDecoration extends OutlinedBorder {
  const MyShapeDecoration({
    required this.borderGradien,
    required this.borderWidth,
  }) : super();

  final Gradient borderGradien;
  final double borderWidth;
  final double bevel = 8.0; // Величина наклона кнопки
}

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


Поменяем настройки темы в файле main.dart так, чтобы стилизировать кнопку только что созданным классом.


      ...
      theme: ThemeData(
        primarySwatch: Colors.blue,
        textButtonTheme: TextButtonThemeData(
            style: ButtonStyle(
          padding: MaterialStateProperty.all(const EdgeInsets.symmetric(horizontal: 16)),
          animationDuration: const Duration(milliseconds: 1500),
          shape: MaterialStateProperty.resolveWith((states) {
            if (states.contains(MaterialState.pressed)) {
              return const MyShapeDecoration(
                borderGradien: LinearGradient(colors: [Colors.red, Colors.red]),
                borderWidth: 6,
              );
            }

            return const MyShapeDecoration(
              borderGradien: LinearGradient(colors: [Colors.purple, Colors.green, Colors.yellow]),
              borderWidth: 2,
            );
          }),
        )),
      ),
      ...

Далее будем работать только с классом MyShapeDecoration.


В класс добавим функцию demensions. Эта функция должна вернуть отступы от краёв элемента, за которые запрещается выходить внутреннему содержимому элемента.


Button dimensions


  @override
  EdgeInsetsGeometry get dimensions {
    return EdgeInsets.symmetric(vertical: borderWidth, horizontal: bevel / 2 + borderWidth);
  }

Далее добавим функции lerpFrom и lerpTo. Они нужны для того, чтобы формировать копии класса во время переходных состояний. Параметр t принимает значения от 0 до 1 и обозначяет текущее положение анимации перехода между двумя состояниями.


  @override
  ShapeBorder? lerpFrom(ShapeBorder? a, double t) {
    if (a is MyShapeDecoration) {
      return MyShapeDecoration(
        borderGradien: Gradient.lerp(a.borderGradien, borderGradien, t)!,
        borderWidth: lerpDouble(a.borderWidth, borderWidth, t)!,
      );
    }
    return super.lerpFrom(a, t);
  }

  @override
  ShapeBorder? lerpTo(ShapeBorder? b, double t) {
    if (b is MyShapeDecoration) {
      return MyShapeDecoration(
        borderGradien: Gradient.lerp(borderGradien, b.borderGradien, t)!,
        borderWidth: lerpDouble(borderWidth, b.borderWidth, t)!,
      );
    }
    return super.lerpFrom(b, t);
  }

Добавим функцию copyWith для копирования объекта класса с модифицированными параметрами.


  @override
  OutlinedBorder copyWith({Gradient? borderGradien, double? borderWidth, BorderSide? side}) {
    return MyShapeDecoration(
      borderGradien: borderGradien ?? this.borderGradien,
      borderWidth: borderWidth ?? this.borderWidth,
    );
  }

Функция scale должна возвращать копию объекта класса с учётом масштабирования элемента управления на множитель t.


  @override
  ShapeBorder scale(double t) {
    return MyShapeDecoration(
      borderGradien: borderGradien.scale(t),
      borderWidth: borderWidth * t,
    );
  }

Добавим функции getInnerPath и getOuterPath. В идеале они должны возвращать пути внешней и внутренней границ элементов.


Button dimensions


Но в нашем случае, для простоты, можно реализовать только одну из этих функций.


  @override
  Path getInnerPath(Rect rect, {TextDirection? textDirection}) {
    final path = Path();
    path.moveTo(rect.left + bevel, rect.top);
    path.lineTo(rect.right, rect.top);
    path.lineTo(rect.right - bevel, rect.bottom);
    path.lineTo(rect.left, rect.bottom);
    path.close();
    return path;
  }

  @override
  Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
    return getInnerPath(rect, textDirection: textDirection);
  }

В процедуре paint рисуем на canvas необходимое нам декорирование элемента.


  @override
  void paint(Canvas canvas, Rect rect, {double? gapStart, double gapExtent = 0.0, double gapPercentage = 0.0, TextDirection? textDirection}) {
    final shader = borderGradien.createShader(rect);

    final paint = Paint()
      ..shader = shader
      ..strokeWidth = borderWidth
      ..style = PaintingStyle.stroke;

    final innerPath = getInnerPath(rect, textDirection: textDirection);
    canvas.drawPath(innerPath, paint);
  }

Осталось внедрить функции сравнения объектов класса, чтобы Flutter мог определить, что объект изменился и нужно заново его нарисовать.


  @override
  bool operator ==(Object other) {
    if (other.runtimeType != runtimeType) return false;
    return other is MyShapeDecoration && other.side == side && other.borderWidth == borderWidth && other.borderGradien == borderGradien;
  }

  @override
  int get hashCode => hashValues(side, borderWidth, borderGradien);

По желанию можно реализовать функцию преобразования объекта в строку.


  @override
  String toString() {
    return '${objectRuntimeType(this, 'MyShapeDecoration')}($side, $borderWidth, $borderGradien)';
  }

На этом всё! Полный код класса MyShapeDecoration доступен по ссылке.


Теперь можем запускать приложение. Форма кнопки изменилась, и при нажатии у неё меняется граница. Вы можете самостоятельно попробовать применить только что созданный класс для других элементов управления, например, для Chip.


Chip example


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


Заключение


Мы научились изменять внешний вид стандартных элементов управления Flutter на примере кнопки.


Если вам не нужна сложная кастомизация, а достаточно лишь изменения тени, добавления свечения или изменения фона, вы можете воспользоваться моим плагином control_style. На странице плагина есть ссылка на web-приложение, чтобы "поиграть" с разными вариантами декорирования элементов управления.

Комментарии (1)


  1. NekrodNIK
    18.05.2022 06:48
    +1

    Чаще всего, когда нужен свой уникальный дизайн, надо выбросить Material виджеты и писать с нуля. Такая стилизация, что была показана вами - хороша в отдельных случаях, когда нужна вот такая особая кнопка и всë, но строить весь дизайн костылями видоизменяя material - сомнительно, лучше либо принять Material Design, либо писать всë с нуля. Но за статью спасибо, в отдельных случаях это полезно!