Flutter. Własny widget. Implementacja krok po kroku.

Przy wykorzystaniu technologii Flutter pokażę Ci, w jaki sposób zaimplementować animowaną listę przycisków.

Flutter. Własny widget. Implementacja krok po kroku.

Dzień dobry panie Flutter! Na pierwszy wpis o tej technologii zbierałem się stosunkowo długo, ale oto jest. Zapraszam do lektury!

O czym dzisiaj? Przedstawię Ci, w jaki sposób wykonać kontrolkę taką jaką widzisz poniżej po prawej stronie wykorzystując język Dart oraz platformę Flutter.

Co dokładnie?

To, co chcemy zrobić to kontrolka, które umożliwi nam dodanie ostylowanej listy przycisków. W jednej chwili może być tylko aktywny jeden z nich. Każdy z nich posiada przypisany kolor oraz identyfikator w postaci litery.

Stanem przycisków będzie zarządzać kontrolka – rodzic. Zaczniemy właśnie od niej.

Czego się dowiesz?

  • Jak pobrać wysokość systemowego status bara i dostosować do niego poprawnie widok.
  • Jak nadać kolor przycisku.
  • Przedstawię Ci bibliotekę TinyColor.
  • Jak powiadamiać o zdarzeniach inny widget.
  • W jaki sposób płynnie zmienić kolor tła widgetu.
  • Jak stworzyć własną listę widgetów.
  • W jaki sposób zarządzać dynamicznie przestrzenią (proporcje szerokości widoków)

Kontrolka – rodzic

Nasz widget będzie dynamiczny. Będzie pozwalał na dodawanie dowolnej liczby przycisków. W celu dodania nowej pozycji wystarczy, że klient skorzysta z metody addButton oraz przekaże trzy podstawowe parametry: id przycisku, literę oraz kolor. Klasa TinyColor pochodzi z biblioteki dokładnie o takiej nazwie. Serdecznie ją polecam ze względu na masę ułatwień, które udostępnia, jeżeli chodzi o pracę z kolorami. Dodaj następujący wpis do pliku pubspec.yaml

tinycolor: ^1.0.2

W celu wyświetlenia listy przycisków utworzyłem klasę ButtonInfo, która posiada właściwości, które opisują pojedynczy element. Podczas tworzenia listy przycisków (mam na myśli UI) każdy z elementów listy pozwoli utworzyć odpowiednio stylizowany element . Zwróć uwagę na metodę _buildContent, w niej korzystamy z widgetu ListView. Przekazujemy ilość elementów listy oraz sposób na utworzenie pojedynczego elementu. Póki co jest to uproszczony element.

Wkrótce będzie się działo!

class ButtonsListControl extends StatefulWidget {
  final _state = _ButtonsListControl();

  ButtonsListControl();

  void addButton(String id, String letter, TinyColor color) {
    _state.addButton(id, letter, color);
  }

  @override
  State<StatefulWidget> createState() {
    return _state;
  }
}

class _ButtonsListControl extends State<ButtonsListControl> {
  final buttons = List<ButtonInfo>();

  void addButton(String id, String letter, TinyColor color) {
    buttons.add(ButtonInfo(id, letter, color, buttons.length == 0));
  }

  @override
  Widget build(BuildContext context) {
    return _buildContent();
  }

  Widget _buildContent() {
    return ListView.builder(
      itemCount: buttons.length,
      itemBuilder: (BuildContext context, int position) {
        final item = buttons[position];

        return Container(
          width: 32,
          height: 32,
          color: item.buttonColor.color,
        );
      },
    );
  }
}

class ButtonInfo {
  final String id;
  final String letter;
  TinyColor buttonColor;
  bool isSelected;

  ButtonInfo(this.id, this.letter, this.buttonColor, this.isSelected);
}

Świetnie! Stwórzmy widok główny, gdzie sprawdzimy działanie naszej nowej kontrolki.

Widok testowy

Poniżej przedstawiam kod widoku głównego.

class DashboardPage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _DashboardPage();
  }
}

class _DashboardPage extends State<DashboardPage> {
  TinyColor containerColor = ColorsStyles.FirstColor;
  double statusBarHeight = 0.0;

  @override
  Widget build(BuildContext context) {
    statusBarHeight = MediaQuery.of(context).padding.top;

    return Scaffold(
        body: Padding(
      padding: EdgeInsets.only(top: statusBarHeight),
      child: Row(
        children: <Widget>[
          Expanded(
            flex: 3,
            child: Container(
              color: containerColor.darken(10).color,
              child: _buildLeftContainer(),
            ),
          ),
          Expanded(child: _buildRightBar()),
        ],
      ),
    ));
  }

  Widget _buildLeftContainer() {
    return Padding(
        padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
        child: Container(color: containerColor.color));
  }

  Widget _buildRightBar() {
    return Container(
      color: ColorsStyles.BlackColor.color,
      child: Padding(
          padding: const EdgeInsets.all(24.0), child: _buildButtons()),
    );
  }

  Widget _buildButtons() {
    final control = ButtonsListControl(this);
    control.addButton("1", "W", ColorsStyles.FirstColor);
    control.addButton("2", "F", ColorsStyles.SecondColor);
    control.addButton("3", "S", ColorsStyles.ThirdColor);
    control.addButton("4", "P", ColorsStyles.FourthColor);
    return control;
  }
}

Klasa pomocnicza służąca jako repozytorium kolorów.

class ColorsStyles
{
  static final FirstColor = TinyColor.fromString("A362EA");
  static final SecondColor = TinyColor.fromString("E4EA62");
  static final ThirdColor = TinyColor.fromString("629FEA");
  static final FourthColor = TinyColor.fromString("EA6262");
}

Spieszę z wyjaśnieniem. Widok podzieliłem na dwie kolumny w stosunku 3:1. Pozwala na to widget Expanded i jego właściwość flex. Pierwsza kolumna będzie odpowiadała za wyświetlenie aktualnie wybranego kolory przycisku (wkrótce dodamy nasłuchiwanie na aktualnie wybrany przycisk). Kolumna druga zawiera naszą kontrolkę.

W celu obsłużenia problemu z rysowaniem UI pod status barem (belka z godziną oraz naładowaniem baterii) korzystam z obiektu MediaQuery i pobrania właściwości top właściwości padding. To właśnie ona zawiera wysokość status bara. Tę właśnie wartość nadajemy paddingowi naszego widoku (właściwość statusBarHeight).

Zakładam, że pozostała część kodu jest zrozumiała.

Powinieneś otrzymać poniższy rezultat, który nie jest zbyt imponujący. Naprawmy to!

Przycisk

Dodaj nową klasę o nazwie LetterButton. Nasz przycisk ma dwa stany: kliknięty lub nie. W zależności od stanu zmienia się jego kolor. Kolorowana jest litera (niekliknięty) lub tło (kliknięty). Kod, który pozwoli na utworzenie przycisku. Zaraz zajmiemy się jego stylowaniem.

class LetterButton extends StatefulWidget {
  ButtonInfo buttonInfo;

  LetterButton(this.buttonInfo);

  @override
  State<StatefulWidget> createState() {
    return _LetterButton();
  }
}

class _LetterButton extends State<LetterButton> {
  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 12),
      child: AspectRatio(aspectRatio: 1 / 1, child: _buildContent()),
    );
  }

  Widget _buildContent() {
    TinyColor backgroundColor = widget.buttonInfo.isSelected
        ? widget.buttonInfo.buttonColor
        : ColorsStyles.BlackLightenColor;

    TinyColor letterColor =
        widget.buttonInfo.isSelected ? TinyColor.fromString("#FFFFFF") : widget.buttonInfo.buttonColor;

    return AnimatedContainer(
      color: backgroundColor.color,
      duration: Duration(milliseconds: 80), 
      child: Center(
          child: Text(widget.buttonInfo.letter,
              style: notepadLetterTextStyle(letterColor.color))),
    );
  }
}

Flutter i box shadow

Nasz przycisk jest opakowany w klasę AnimatedContainer, która udostępnia właściwość decoration. Wykorzystajmy ją do utworzenia zamierzonego efektu trójwymiaru. Zaktualizuj metodę _buildContent w klasie _LetterButton.

Widget _buildContent() {
    TinyColor backgroundColor = widget.buttonInfo.isSelected
        ? widget.buttonInfo.buttonColor
        : ColorsStyles.BlackLightenColor;

    TinyColor letterColor =
        widget.buttonInfo.isSelected ? TinyColor.fromString("#FFFFFF") : widget.buttonInfo.buttonColor;

    return AnimatedContainer(
      duration: Duration(milliseconds: 80),
      decoration: BoxDecoration(
          boxShadow: [
            BoxShadow(
              color: backgroundColor.darken(20).color,
              offset: Offset.fromDirection(1.5, 5),
              spreadRadius: 0,
              blurRadius: 0.0,
            )
          ],
          color: backgroundColor.color,
          borderRadius: BorderRadius.circular(15)),
      child: Center(
          child: Text(widget.buttonInfo.letter,
              style: notepadLetterTextStyle(letterColor.color))),
    );
  }

Efekt zmian przedstawiam poniżej. Teraz przyciski posiadają ładne zaokrąglenie oraz cień.

Informacja o aktualnie wciśniętym przycisku

Pozostało nam dodać logikę odpowiedzialną za informowanie o aktualnie wciśniętym przycisku. Jak to rozwiążemy?

  1. Każdy z tworzonych przycisków będzie przyjmował w konstruktorze niejawną referencję do obiektu ButtonsListControl. Dlaczego niejawną? Utworzymy interfejs (mixin – domieszka – w języku Dart) z jedną deklaracją metody: onItemSelected
  2. Zaimplementujemy utworzony interfejs w klasie ButtonsListControl.
  3. Każdy z przycisków po kliknięciu wywoła na referencji właśnie tę metodę, to sprawi, że ButtonsListControl dowie się o kliknięciu i odpowiednio zaktualizuje właściwości isSelected na liście obiektów ButtonsInfo.

Pozwól, że zamiast używać słowa domieszka, spolszczę angielskie mixin 🙂 Od teraz będziemy korzystać z mixinów!

mixin ButtonControlListener {
  void onClick(String id);
}

Zaktualizuj klasę _ButtonListControl. Wymagane zmiany pogrubiłem. Ciało metody onClick opakowałem jako parametr metody setState(). Flutter wykorzystuje wywołanie tej metody do przebudowania i aktualizacji widoku. Dzięki temu po zaktualizowaniu listy przycisków zostanie przebudowany widok i zobaczymy zmiany.

class _ButtonsListControl extends State<ButtonsListControl>
    implements ButtonControlListener {
  final buttons = List<ButtonInfo>();

  void addButton(String id, String letter, TinyColor color) {
    buttons.add(ButtonInfo(id, letter, color, buttons.length == 0));
  }

  @override
  Widget build(BuildContext context) {
    return _buildContent();
  }

  Widget _buildContent() {
    return ListView.builder(
      itemCount: buttons.length,
      itemBuilder: (BuildContext context, int position) {
        final item = buttons[position];
        return LetterButton(item, this);
      },
    );
  }

  @override
  void onClick(String id) {
    setState(() {
      buttons.forEach((item) {
        item.isSelected = item.id == id;
        if (item.isSelected) print("Selected: $id");
      });
    });
  }
}

Pozostało zaktualizować klasę przycisku. Opakuj kontener w klasę GestureDetector. Dzięki niej możemy reagować na kliknięcia w widok. W wywołaniu przekazujemy id klikniętego przycisku.

class LetterButton extends StatefulWidget {
  final ButtonControlListener listener;

  ButtonInfo buttonInfo;

  LetterButton(this.buttonInfo, this.listener);

  @override
  State<StatefulWidget> createState() {
    return _LetterButton();
  }
}

class _LetterButton extends State<LetterButton> {
  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 12),
      child: AspectRatio(aspectRatio: 1 / 1, child: _buildContent()),
    );
  }

  Widget _buildContent() {
    TinyColor backgroundColor = widget.buttonInfo.isSelected
        ? widget.buttonInfo.buttonColor
        : ColorsStyles.BlackLightenColor;

    TinyColor letterColor = widget.buttonInfo.isSelected
        ? TinyColor.fromString("#FFFFFF")
        : widget.buttonInfo.buttonColor;

    return GestureDetector(
      onTap: () {
        widget.listener.onClick(widget.buttonInfo.id);
      },
      child: AnimatedContainer(
        duration: Duration(milliseconds: 80),
        decoration: BoxDecoration(
            boxShadow: [
              BoxShadow(
                color: backgroundColor.darken(20).color,
                offset: Offset.fromDirection(1.5, 5),
                spreadRadius: 0,
                blurRadius: 0.0,
              )
            ],
            color: backgroundColor.color,
            borderRadius: BorderRadius.circular(15)),
        child: Center(
            child: Text(widget.buttonInfo.letter,
                style: notepadLetterTextStyle(letterColor.color))),
      ),
    );
  }
}

Ostatnia prosta

Nasza kontrolka jest powiadamiana o kliknięciach, co pozwala na zmianę aktualnie wybranego przycisku i wygaszeniu pozostałych. Super, ale przydałoby się powiadomić o wybranych przycisku klientów korzystających z kontrolki.

Utwórz drugi mixin:

mixin ButtonsListControlListener {
  void onItemSelected(ButtonInfo item);
}

Teraz nasza główna kontrolka jako parametr konstruktora przyjmie ButtonsListControlListener, a widok główny zaimplementuje tego mixina. Wymagane zmiany:

class ButtonsListControl extends StatefulWidget {
  final ButtonsListControlListener listener;
  final _state = _ButtonsListControl();

  ButtonsListControl(this.listener);

  void addButton(String id, String letter, TinyColor color) {
    _state.addButton(id, letter, color);
  }

  @override
  State<StatefulWidget> createState() {
    return _state;
  }
}

class _ButtonsListControl extends State<ButtonsListControl>
    implements ButtonControlListener {
  final buttons = List<ButtonInfo>();

  void addButton(String id, String letter, TinyColor color) {
    buttons.add(ButtonInfo(id, letter, color, buttons.length == 0));
  }

  @override
  Widget build(BuildContext context) {
    return _buildContent();
  }

  Widget _buildContent() {
    return ListView.builder(
      itemCount: buttons.length,
      itemBuilder: (BuildContext context, int position) {
        final item = buttons[position];
        return LetterButton(item, this);
      },
    );
  }

  @override
  void onClick(String id) {
    setState(() {
      buttons.forEach((item) {
        item.isSelected = item.id == id;
        if (item.isSelected) widget.listener.onItemSelected(item);
      });
    });
  }
}

Końcowa definicja widoku głównego wraz zaznaczonymi zmianami. Metoda onItemSelected, także korzysta z setState, aby zaktualizować widok i podmienić kolor lewego kontenera.

class DashboardPage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _DashboardPage();
  }
}

class _DashboardPage extends State<DashboardPage> implements ButtonsListControlListener {
  TinyColor containerColor = ColorsStyles.FirstColor;
  double statusBarHeight = 0.0;

  @override
  Widget build(BuildContext context) {
    statusBarHeight = MediaQuery.of(context).padding.top;

    return Scaffold(
        body: Padding(
      padding: EdgeInsets.only(top: statusBarHeight),
      child: Row(
        children: <Widget>[
          Expanded(
            flex: 3,
            child: Container(
              color: containerColor.darken(10).color,
              child: _buildLeftContainer(),
            ),
          ),
          Expanded(child: _buildRightBar()),
        ],
      ),
    ));
  }

  Widget _buildLeftContainer() {
    return Padding(
        padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
        child: Container(color: containerColor.color));
  }

  Widget _buildRightBar() {
    return Container(
      color: ColorsStyles.BlackColor.color,
      child: Padding(
          padding: const EdgeInsets.all(24.0), child: _buildButtons()),
    );
  }

  Widget _buildButtons() {
    final control = ButtonsListControl(this);
    control.addButton("1", "W", ColorsStyles.FirstColor);
    control.addButton("2", "F", ColorsStyles.SecondColor);
    control.addButton("3", "S", ColorsStyles.ThirdColor);
    control.addButton("4", "P", ColorsStyles.FourthColor);
    return control;
  }

  @override
  void onItemSelected(ButtonInfo item) {
    setState(() {
      containerColor = item.buttonColor;
    });
  }
}

Pierwsze koty za płoty

Jaram się tą technologią niesamowicie, co spowodowało, że jestem w trakcie pisania nowej aplikacji opartej o Fluttera.

Świetlaną przyszłość ma ta technologia.
Do następnego!

PS. Sprawdź jak będzie wyglądał widok główny gdy zamienisz widgety Container na AnimatedContainer 🙂