Создание динамического алфавитного слайдера для вашего Android-ланчера с помощью Flutter

Создание динамического алфавитного слайдера для вашего Android-ланчера с помощью Flutter

FlutterPulse

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

✨ Создайте потрясающий алфавитный слайдер с помощью Flutter! Поразительные анимации, отзывчивые жесты и динамическое позиционирование — как в Niagara Launcher.

Привет, энтузиасты Flutter! 👋 Я рад начать серию разработок Samagra, где я поделюсь своим опытом создания открытого Android-ланчера, который сочетает в себе полноту и настраиваемость.

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

(Демонстрация работы алфавитного слайдера в ланчере Samagra)

🔍 Что мы создаем

Наш алфавитный слайдер будет иметь следующие ключевые функции:

  • Вертикальный индекс букв, реагирующий на касание пользователя
  • Визуальная обратная связь при перетаскивании (с круглым индикатором букв)
  • Динамическое позиционирование, следующее за пальцем
  • Адаптивная анимация колоколообразной кривой для соседних букв
  • Поддержка как левого, так и правого выравнивания
  • Чистый, настраиваемый дизайн

🧩 Настройка проекта

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

Сначала создайте новый проект Flutter и замените содержимое файла main.dart следующим кодом:

import 'dart:math' as math;
import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Alphabet Slider',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: Scaffold(
        body: Center(
          child: AlphabetSlider(
            onLetterSelected: (val) {
              // Обработка пользовательской логики после выбора буквы
            },
            alignment: Alignment.centerRight,
            letterHeight: 20,
          ),
        ),
      ),
    );
  }
}

Здесь мы настроили базовое приложение Flutter с простым макетом, содержащим виджет AlphabetSlider. Я разместил его справа на экране с высотой букв 20 пикселей.

📝 Создание виджета AlphabetSlider

Теперь давайте реализуем основную функциональность нашего слайдера. Мы создадим виджет с состоянием, который отслеживает взаимодействия пользователя и анимирует соответствующим образом.

class AlphabetSlider extends StatefulWidget {
  final Function(String) onLetterSelected;
  final AlignmentGeometry alignment;
  final double letterHeight;

  const AlphabetSlider({
    super.key,
    required this.onLetterSelected,
    this.alignment = Alignment.centerLeft,
    this.letterHeight = 20,
  });

  @override
  State<AlphabetSlider> createState() => _AlphabetSliderState();
}

Наш виджет принимает три параметра:

  • onLetterSelected: Функция обратного вызова, которая будет вызвана при выборе буквы
  • alignment: Определяет, появляется ли слайдер слева или справа на экране
  • letterHeight: Управляет высотой каждой буквы в слайдере

🎮 Обработка состояния и жестов перетаскивания

Далее давайте реализуем класс состояния со всеми необходимыми переменными и методами для обработки взаимодействий пользователя:

class _AlphabetSliderState extends State<AlphabetSlider>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  String _selectedLetter = '';
  bool _isDragging = false;
  Offset _dragPosition = Offset.zero;
  Offset _startDragPosition = Offset.zero;
  final _columnKey = GlobalKey();
  int _selectedIndex = -1;
  double _initialTopY = 0;
  double _initialBottomY = 0;
  double _topY = -999999;
  double _bottomY = -999999;
  double _itemsHeight = 0;

  final List<String> _alphabet = List.generate(
    26,
    (index) => String.fromCharCode(65 + index),
  );

  @override
  void initState() {
    super.initState();
    _itemsHeight = widget.letterHeight * _alphabet.length;
    _controller = AnimationController(
      duration: const Duration(milliseconds: 50),
      vsync: this,
    );
    CurvedAnimation(parent: _controller, curve: Curves.easeOutBack);
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _getColumnYCoordinates();
    });
  }

  void _getColumnYCoordinates() {
    final RenderBox? columnBox =
        _columnKey.currentContext?.findRenderObject() as RenderBox?;
    if (columnBox != null) {
      final Offset columnOffset = columnBox.localToGlobal(Offset.zero);
      _initialTopY = columnOffset.dy;
      _initialBottomY = _initialTopY + columnBox.size.height;
    }
  }

При инициализации состояния мы:

  1. Генерируем список алфавита (A-Z)
  2. Настраиваем контроллер анимации для плавных переходов
  3. Вычисляем общую высоту всех букв
  4. Получаем координаты нашей колонки после того, как отрендерируется первый кадр

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

👆 Обработка событий перетаскивания

Теперь давайте реализуем обработчики событий перетаскивания, которые делают наш слайдер интерактивным:

  void _onDragStart(DragStartDetails details) {
    setState(() {
      _isDragging = true;
      _startDragPosition = details.globalPosition;
      _updateSelectionFromPosition(details.globalPosition);
    });
  }

  void _onDragUpdate(DragUpdateDetails details) {
    _updateSelectionFromPosition(details.globalPosition);
  }

  void _resetAlphabetPositionY() {
    _topY = -999999;
    _bottomY = -999999;
  }

  void _updateSelectionFromPosition(Offset globalPosition) {
    if (_topY == -999999) _topY = _initialTopY;
    if (_bottomY == -999999) _bottomY = _initialBottomY;
    final RenderBox? columnBox =
        _columnKey.currentContext?.findRenderObject() as RenderBox?;
    if (columnBox == null) return;
    final Offset localPosition = columnBox.globalToLocal(globalPosition);
    double totalHeight = widget.letterHeight * _alphabet.length;
    double startY = (columnBox.size.height - totalHeight) / 2;
    int index = ((localPosition.dy - startY) / widget.letterHeight).floor();
    index = index.clamp(0, _alphabet.length - 1);
    // Проверяем, находится ли текущая позиция пальца внутри или вне колонки
    bool isInsideColumn =
        globalPosition.dy >= _topY && globalPosition.dy <= _bottomY;
    if (!isInsideColumn) {
      if (globalPosition.dy < _topY) {
        double safeTop = MediaQuery.paddingOf(context).top;
        if (globalPosition.dy >= safeTop) {
          setState(() {
            _topY = globalPosition.dy;
            _bottomY = _topY + _itemsHeight;
          });
        }
      }
      if (globalPosition.dy > _bottomY) {
        double safeBottom =
            MediaQuery.of(context).size.height -
            MediaQuery.paddingOf(context).bottom;
        if (globalPosition.dy <= safeBottom) {
          setState(() {
            _bottomY = globalPosition.dy;
            _topY = _bottomY - _itemsHeight;
          });
        }
      }
    }
    setState(() {
      _dragPosition = globalPosition;
      _selectedLetter = _alphabet[index];
      _selectedIndex = index;
    });
    widget.onLetterSelected(_selectedLetter);
  }

  void _onDragEnd(DragEndDetails details) {
    _resetAlphabetPositionY();
    setState(() {
      _isDragging = false;
      _startDragPosition = Offset.zero;
      _selectedIndex = -1;
    });
    _controller.forward(from: 0.0);
  }

Вся магия происходит в _updateSelectionFromPosition(), которая:

  1. Определяет, на какую букву указывает пользователь на основе позиции пальца
  2. Обновляет позицию списка алфавита, если пользователь перетаскивает за пределы начальных границ
  3. Обеспечивает, чтобы всё оставалось в пределах безопасных зон экрана
  4. Вызывает нашу функцию обратного вызова, чтобы родительские виджеты знали, какая буква была выбрана

Это динамическое позиционирование — выдающаяся особенность! Когда пользователи перетаскивают палец за пределы начального списка алфавита, список следует за ними — подобно тому, как работает алфавитный слайдер в Niagara Launcher. Это создаёт естественное, плавное взаимодействие, которое кажется интуитивным.

🔄 Создание анимации колоколообразной кривой

Теперь перейдём к одному из самых крутых моментов — анимации колоколообразной кривой, которая создаёт волнообразный эффект при перетаскивании:

double _calculateOffset(int index) {
    double screenWidth = MediaQuery.sizeOf(context).width;
    bool startedFromSecondHalf = _startDragPosition.dx > (screenWidth / 2);
    if (!_isDragging || _selectedIndex == -1) return 0.0;
    int distance = (index - _selectedIndex).abs();
    double maxOffset;
    if (widget.alignment == Alignment.centerLeft) {
      if (startedFromSecondHalf) {
        maxOffset = screenWidth * .30;
      } else {
        if (_dragPosition.dx + 60 <= screenWidth - 100) {
          maxOffset = _dragPosition.dx + 60;
        } else {
          maxOffset = screenWidth - 100;
        }
      }
      // Создаём плавную колоколообразную кривую с использованием гауссовой функции
      double divisor = (_dragPosition.dx / 4.0) + 12.0;
      double gaussian = math.exp(-(math.pow(distance, 2) / divisor));
      double offset = maxOffset * gaussian;
      return offset.clamp(0, screenWidth - 85);
    } else {
      double maxOffset;
      if (!startedFromSecondHalf) {
        maxOffset = screenWidth * .30;
      } else {
        maxOffset = (screenWidth - _dragPosition.dx + 60).clamp(
          0,
          screenWidth - 100,
        );
      }
      double divisor = ((screenWidth - _dragPosition.dx) / 4.0) + 12.0;
      double gaussian = math.exp(-(math.pow(distance, 2) / divisor));
      double offset = maxOffset * gaussian;
      return -offset;
    }
  }

Этот метод вычисляет, насколько должна двигаться каждая буква на основе:

  1. Её расстояния от выбранной буквы
  2. Позиции вашего пальца на экране
  3. Выравнивания слайдера по левому или правому краю

Гауссова функция создаёт эту красивую колоколообразную кривую — буквы, близкие к выбранной, двигаются больше, в то время как удалённые буквы двигаются меньше. Делитель регулирует ширину кривой на основе горизонтальной позиции пальца, создавая отзывчивое и динамичное ощущение.

🎨 Создание пользовательского интерфейса

Наконец, давайте реализуем метод build для отображения нашего алфавитного слайдера:

@override
  Widget build(BuildContext context) {
    return GestureDetector(
      onVerticalDragStart: _onDragStart,
      onVerticalDragUpdate: _onDragUpdate,
      onVerticalDragEnd: _onDragEnd,
      child: Container(
        height: double.infinity,
        decoration: BoxDecoration(
          border: Border.all(color: Colors.transparent),
        ),
        child: Stack(
          alignment: widget.alignment,
          children: [
            AnimatedPositioned(
              duration: Duration(milliseconds: 50),
              top:
                  _topY == -999999
                      ? null
                      : _topY.clamp(
                        MediaQuery.paddingOf(context).top,
                        MediaQuery.of(context).size.height -
                            _itemsHeight -
                            MediaQuery.paddingOf(context).bottom,
                      ),
              child: Column(
                key: _columnKey,
                mainAxisSize: MainAxisSize.min,
                mainAxisAlignment: MainAxisAlignment.center,
                children: List.generate(_alphabet.length, (index) {
                  bool isSelected = _alphabet[index] == _selectedLetter;
                  bool isRightAligned =
                      widget.alignment == Alignment.centerRight;
                  return Container(
                    width: MediaQuery.sizeOf(context).width,
                    height: widget.letterHeight,
                    padding: EdgeInsets.fromLTRB(
                      isRightAligned ? 0 : 10,
                      0,
                      isRightAligned ? 10 : 0,
                      0,
                    ),
                    child: AnimatedContainer(
                      duration: Duration(milliseconds: 50),
                      transform: Matrix4.translationValues(
                        _calculateOffset(index),
                        0,
                        0,
                      ),
                      child: Text(
                        _alphabet[index],
                        textAlign:
                            widget.alignment == Alignment.centerLeft
                                ? TextAlign.left
                                : TextAlign.right,
                        style: TextStyle(
                          fontSize: 16,
                          fontWeight:
                              isSelected ? FontWeight.bold : FontWeight.normal,
                          color: isSelected ? Colors.blue : Colors.black,
                        ),
                      ),
                    ),
                  );
                }),
              ),
            ),
            if (_isDragging && _selectedIndex != -1)
              Positioned(
                left:
                    widget.alignment == Alignment.centerLeft
                        ? 30 + _calculateOffset(_selectedIndex)
                        : null,
                right:
                    widget.alignment == Alignment.centerRight
                        ? 30 + _calculateOffset(_selectedIndex).abs()
                        : null,
                top: (_dragPosition.dy - 25).clamp(
                  MediaQuery.paddingOf(context).top,
                  MediaQuery.of(context).size.height -
                      50 -
                      MediaQuery.paddingOf(context).bottom,
                ),
                child: Container(
                  width: 50,
                  height: 50,
                  decoration: BoxDecoration(
                    color: Colors.blue,
                    shape: BoxShape.circle,
                    boxShadow: [
                      BoxShadow(
                        color: Colors.black26,
                        blurRadius: 8,
                        offset: Offset(0, 2),
                      ),
                    ],
                  ),
                  child: Center(
                    child: Text(
                      _selectedLetter,
                      style: TextStyle(
                        color: Colors.white,
                        fontSize: 24,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

Метод build создаёт:

  1. Буквы алфавита, расположенные вертикально, которые анимируются с помощью нашей колоколообразной кривой
  2. Плавающий круглый индикатор, показывающий выбранную букву во время перетаскивания
  3. Поддержка как левого, так и правого выравнивания с правильным выравниванием текста

🚀 Объединение всего

Вот и всё! Мы создали полностью функциональный слайдер алфавита с некоторыми впечатляющими функциями:

  1. Динамическое позиционирование: Слайдер алфавита следует за вашим пальцем, если вы перетаскиваете за его пределы
  2. Адаптивная колоколообразная кривая: Буквы рядом с выбранной анимируются наружу плавным, волнообразным движением
  3. Визуальная обратная связь: Круглый индикатор показывает текущую выбранную букву
  4. Настраиваемое выравнивание: Слайдер работает как на левой, так и на правой сторонах экрана
  5. Обработка безопасной области: Всё остаётся в пределах видимой области экрана

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

🔍 Следующие шаги

Вы уже можете дополнительно настроить этот компонент своим стилем:

  • Измените цвета и стили шрифтов
  • Настройте тайминг и кривые анимации
  • Измените поведение колоколообразной кривой
  • Добавьте тактильную обратную связь
  • Включите дополнительные индикаторы или метки

🔗 Свяжитесь с нами

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

Оставайтесь в курсе

  • LinkedIn: linkedin.com/in/jaydippawar — Подписывайтесь на регулярные обновления разработки и инсайты по Flutter
  • GitHub: github.com/jaydip-pawar — Поставьте звезду нашему репозиторию, чтобы отслеживать наш прогресс и внести свой вклад в проект

🌟 Присоединяйтесь к путешествию Samagra

Мы с нетерпением ждём создания Samagra как открытой альтернативы премиальным запускателям, принося мощную настраиваемость без сложности или платных стен. Если вы хотите внести свой вклад или следить за нашим развитием, свяжитесь с нами и оставайтесь на связи для следующих статей в этой серии!

В следующей статье мы углубимся в получение списка запускаемых приложений, прослушивание изменений приложений, запуск приложений и даже их удаление — всё это, обеспечивая плавную интеграцию Flutter-Kotlin. Оставайтесь на связи! 🚀

Report Page