← Назад к вопросам

Реализовать кастомный виджет рейтинга (звёзды)

1.0 Junior🔥 181 комментариев
#Flutter виджеты#Анимации

Условие

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

Требования

  1. Отображение рейтинга от 0 до 5 звёзд
  2. Поддержка половинных звёзд (0.5)
  3. Возможность выбора рейтинга нажатием
  4. Настраиваемые параметры: размер, цвет, количество звёзд

Интерфейс виджета

RatingWidget({
  required double rating,
  int starCount = 5,
  double size = 24,
  Color activeColor = Colors.amber,
  Color inactiveColor = Colors.grey,
  ValueChanged<double>? onRatingChanged,
})

Дополнительные баллы

  • Анимация при изменении рейтинга
  • Поддержка свайпа для выбора
  • Режим только для чтения
  • Кастомные иконки вместо звёзд

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

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Решение: Flutter кастомный виджет рейтинга со звёздами

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

1. Основной виджет RatingWidget (lib/widgets/rating_widget.dart)

import "package:flutter/material.dart";

class RatingWidget extends StatefulWidget {
  final double rating;
  final int starCount;
  final double size;
  final Color activeColor;
  final Color inactiveColor;
  final ValueChanged<double>? onRatingChanged;
  final bool readOnly;
  final IconData? customIcon;
  final bool allowHalfRating;

  const RatingWidget({
    required this.rating,
    this.starCount = 5,
    this.size = 24,
    this.activeColor = Colors.amber,
    this.inactiveColor = Colors.grey,
    this.onRatingChanged,
    this.readOnly = false,
    this.customIcon,
    this.allowHalfRating = true,
    Key? key,
  })
      : assert(rating >= 0 && rating <= starCount),
        assert(starCount > 0),
        assert(size > 0),
        super(key: key);

  @override
  State<RatingWidget> createState() => _RatingWidgetState();
}

class _RatingWidgetState extends State<RatingWidget>
    with SingleTickerProviderStateMixin {
  late double _hoverRating;
  late AnimationController _animationController;
  late Animation<double> _scaleAnimation;

  @override
  void initState() {
    super.initState();
    _hoverRating = widget.rating;

    _animationController = AnimationController(
      duration: const Duration(milliseconds: 300),
      vsync: this,
    );

    _scaleAnimation = Tween<double>(begin: 1.0, end: 1.1).animate(
      CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
    );
  }

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

  @override
  void didUpdateWidget(RatingWidget oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.rating != widget.rating) {
      _hoverRating = widget.rating;
    }
  }

  void _onStarHover(int starIndex, double position) {
    if (widget.readOnly) return;

    double newRating = (starIndex + 1).toDouble();

    if (widget.allowHalfRating) {
      // Позиция в пределах звезды (0 = левый край, 1 = правый край)
      if (position < 0.5) {
        newRating -= 0.5;
      }
    }

    setState(() {
      _hoverRating = newRating;
    });
  }

  void _onStarTap(int starIndex, double position) {
    if (widget.readOnly) return;

    double newRating = (starIndex + 1).toDouble();

    if (widget.allowHalfRating) {
      if (position < 0.5) {
        newRating -= 0.5;
      }
    }

    _animationController.forward().then((_) {
      _animationController.reverse();
    });

    widget.onRatingChanged?.call(newRating);
  }

  bool _isStarFilled(int index, double rating) {
    return index < rating.floor();
  }

  bool _isStarHalf(int index, double rating) {
    return index == rating.floor() && rating % 1 != 0;
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onHorizontalDragUpdate: (details) {
        if (widget.readOnly) return;

        // Получить позицию в виджете
        final RenderBox box = context.findRenderObject() as RenderBox;
        final localPos = box.globalToLocal(details.globalPosition);
        final starWidth = box.size.width / widget.starCount;
        final starIndex = (localPos.dx / starWidth).floor();

        if (starIndex >= 0 && starIndex < widget.starCount) {
          final position = (localPos.dx % starWidth) / starWidth;
          _onStarHover(starIndex, position);
        }
      },
      onHorizontalDragEnd: (details) {
        if (widget.readOnly) return;

        final RenderBox box = context.findRenderObject() as RenderBox;
        final starWidth = box.size.width / widget.starCount;
        final starIndex = (box.size.width / (starWidth * 2)).floor();

        if (starIndex >= 0 && starIndex < widget.starCount) {
          _onStarTap(starIndex, 0.5);
        }
      },
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: List.generate(
          widget.starCount,
          (index) => _buildStar(index, _hoverRating),
        ),
      ),
    );
  }

  Widget _buildStar(int index, double rating) {
    return Expanded(
      child: GestureDetector(
        onTapDown: (details) {
          if (widget.readOnly) return;

          final RenderBox box = context.findRenderObject() as RenderBox;
          final localPos = box.globalToLocal(details.globalPosition);
          final position = localPos.dx / box.size.width;

          _onStarTap(index, position);
        },
        onHover: (isHovering) {
          if (isHovering && !widget.readOnly) {
            setState(() {
              _hoverRating = (index + 1).toDouble();
            });
          }
        },
        child: MouseRegion(
          cursor: widget.readOnly
              ? SystemMouseCursors.basic
              : SystemMouseCursors.click,
          child: CustomPaint(
            painter: StarPainter(
              rating: rating,
              starIndex: index,
              activeColor: widget.activeColor,
              inactiveColor: widget.inactiveColor,
              size: widget.size,
              allowHalfRating: widget.allowHalfRating,
            ),
            size: Size(widget.size, widget.size),
          ),
        ),
      ),
    );
  }
}

2. CustomPainter для рисования звёзд (lib/widgets/star_painter.dart)

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

class StarPainter extends CustomPainter {
  final double rating;
  final int starIndex;
  final Color activeColor;
  final Color inactiveColor;
  final double size;
  final bool allowHalfRating;

  StarPainter({
    required this.rating,
    required this.starIndex,
    required this.activeColor,
    required this.inactiveColor,
    required this.size,
    required this.allowHalfRating,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = this.size / 2;

    // Определяем состояние звезды
    final isFilled = starIndex < rating.floor();
    final isHalf = allowHalfRating &&
        starIndex == rating.floor() &&
        rating % 1 != 0;

    if (isFilled) {
      _drawStar(canvas, center, radius, activeColor, 1.0);
    } else if (isHalf) {
      _drawHalfStar(canvas, center, radius, activeColor, inactiveColor);
    } else {
      _drawStar(canvas, center, radius, inactiveColor, 1.0);
    }
  }

  void _drawStar(
    Canvas canvas,
    Offset center,
    double radius,
    Color color,
    double opacity,
  ) {
    final paint = Paint()
      ..color = color.withOpacity(opacity)
      ..style = PaintingStyle.fill;

    final path = _createStarPath(center, radius);
    canvas.drawPath(path, paint);

    // Обводка
    final strokePaint = Paint()
      ..color = color.withOpacity(opacity * 0.7)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 1;
    canvas.drawPath(path, strokePaint);
  }

  void _drawHalfStar(
    Canvas canvas,
    Offset center,
    double radius,
    Color activeColor,
    Color inactiveColor,
  ) {
    // Сначала рисуем неактивную звезду
    _drawStar(canvas, center, radius, inactiveColor, 1.0);

    // Потом клипируем половину и рисуем активную
    canvas.save();
    canvas.clipRect(Rect.fromLTWH(0, 0, center.dx, canvas.getRecords().isEmpty ? radius * 2 : double.infinity));
    _drawStar(canvas, center, radius, activeColor, 1.0);
    canvas.restore();
  }

  Path _createStarPath(Offset center, double radius) {
    const int spikes = 5;
    final path = Path();
    final angle = 2 * math.pi / spikes;

    for (int i = 0; i < spikes; i++) {
      final radian1 = i * angle - math.pi / 2;
      final radian2 = radian1 + angle / 2;

      final point1 = Offset(
        center.dx + radius * math.cos(radian1),
        center.dy + radius * math.sin(radian1),
      );

      final point2 = Offset(
        center.dx + (radius * 0.4) * math.cos(radian2),
        center.dy + (radius * 0.4) * math.sin(radian2),
      );

      if (i == 0) {
        path.moveTo(point1.dx, point1.dy);
      } else {
        path.lineTo(point1.dx, point1.dy);
      }

      path.lineTo(point2.dx, point2.dy);
    }

    path.close();
    return path;
  }

  @override
  bool shouldRepaint(StarPainter oldDelegate) {
    return oldDelegate.rating != rating ||
        oldDelegate.activeColor != activeColor ||
        oldDelegate.inactiveColor != inactiveColor;
  }
}

3. Альтернативная версия с иконками (lib/widgets/rating_widget_v2.dart)

import "package:flutter/material.dart";

class RatingWidgetV2 extends StatefulWidget {
  final double rating;
  final int starCount;
  final double size;
  final Color activeColor;
  final Color inactiveColor;
  final ValueChanged<double>? onRatingChanged;
  final bool readOnly;
  final IconData activeIcon;
  final IconData inactiveIcon;
  final bool allowHalfRating;

  const RatingWidgetV2({
    required this.rating,
    this.starCount = 5,
    this.size = 24,
    this.activeColor = Colors.amber,
    this.inactiveColor = Colors.grey,
    this.onRatingChanged,
    this.readOnly = false,
    this.activeIcon = Icons.star,
    this.inactiveIcon = Icons.star_outline,
    this.allowHalfRating = true,
    Key? key,
  }) : super(key: key);

  @override
  State<RatingWidgetV2> createState() => _RatingWidgetV2State();
}

class _RatingWidgetV2State extends State<RatingWidgetV2>
    with SingleTickerProviderStateMixin {
  late double _hoverRating;
  late AnimationController _animationController;
  late Animation<double> _scaleAnimation;

  @override
  void initState() {
    super.initState();
    _hoverRating = widget.rating;
    _animationController = AnimationController(
      duration: const Duration(milliseconds: 300),
      vsync: this,
    );
    _scaleAnimation = Tween<double>(begin: 1.0, end: 1.2).animate(
      CurvedAnimation(parent: _animationController, curve: Curves.elasticOut),
    );
  }

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

  void _onStarTap(int index) {
    if (widget.readOnly) return;

    double newRating = (index + 1).toDouble();
    _animationController.forward().then((_) {
      _animationController.reverse();
    });
    widget.onRatingChanged?.call(newRating);
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onHorizontalDragUpdate: (details) {
        if (widget.readOnly) return;
        // Логика для свайпа
      },
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: List.generate(
          widget.starCount,
          (index) {
            final isFilled = index < _hoverRating;
            final isHalf = widget.allowHalfRating &&
                index == _hoverRating.floor() &&
                _hoverRating % 1 != 0;

            return ScaleTransition(
              scale: _animationController.isAnimating &&
                      index <= _hoverRating.floor()
                  ? _scaleAnimation
                  : AlwaysStoppedAnimation(1.0),
              child: GestureDetector(
                onTap: () => _onStarTap(index),
                child: Icon(
                  isFilled ? widget.activeIcon : widget.inactiveIcon,
                  size: widget.size,
                  color: isFilled
                      ? widget.activeColor
                      : isHalf
                          ? Colors.orange
                          : widget.inactiveColor,
                ),
              ),
            );
          },
        ),
      ),
    );
  }
}

4. Пример использования (lib/screens/rating_demo_screen.dart)

import "package:flutter/material.dart";
import "../widgets/rating_widget.dart";
import "../widgets/rating_widget_v2.dart";

class RatingDemoScreen extends StatefulWidget {
  const RatingDemoScreen({Key? key}) : super(key: key);

  @override
  State<RatingDemoScreen> createState() => _RatingDemoScreenState();
}

class _RatingDemoScreenState extends State<RatingDemoScreen> {
  double _customRating = 3.5;
  double _iconRating = 4.0;
  final double _readOnlyRating = 2.5;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Rating Widget Demo"),
        elevation: 0,
        backgroundColor: Colors.blue,
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(24.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // Кастомный виджет звёзд
            Text(
              "Кастомный рейтинг (звёзды)",
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            Row(
              children: [
                RatingWidget(
                  rating: _customRating,
                  size: 40,
                  activeColor: Colors.amber,
                  onRatingChanged: (rating) {
                    setState(() => _customRating = rating);
                  },
                ),
                const SizedBox(width: 16),
                Text(
                  "${_customRating.toStringAsFixed(1)} / 5.0",
                  style: const TextStyle(fontSize: 18),
                ),
              ],
            ),
            const SizedBox(height: 32),
            // Иконочный рейтинг
            Text(
              "Иконочный рейтинг",
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            Row(
              children: [
                RatingWidgetV2(
                  rating: _iconRating,
                  size: 36,
                  activeIcon: Icons.favorite,
                  inactiveIcon: Icons.favorite_border,
                  activeColor: Colors.red,
                  inactiveColor: Colors.grey,
                  onRatingChanged: (rating) {
                    setState(() => _iconRating = rating);
                  },
                ),
                const SizedBox(width: 16),
                Text(
                  "${_iconRating.toStringAsFixed(1)} / 5.0",
                  style: const TextStyle(fontSize: 18),
                ),
              ],
            ),
            const SizedBox(height: 32),
            // Только для чтения
            Text(
              "Режим только для чтения",
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            Row(
              children: [
                RatingWidget(
                  rating: _readOnlyRating,
                  size: 32,
                  readOnly: true,
                ),
                const SizedBox(width: 16),
                Text(
                  "${_readOnlyRating.toStringAsFixed(1)} / 5.0",
                  style: const TextStyle(fontSize: 18),
                ),
              ],
            ),
            const SizedBox(height: 32),
            // Разные размеры
            Text(
              "Разные размеры",
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 16),
            Column(
              children: [
                for (final size in [16.0, 24.0, 32.0, 48.0])
                  Padding(
                    padding: const EdgeInsets.symmetric(vertical: 8.0),
                    child: RatingWidget(
                      rating: 3.5,
                      size: size,
                      onRatingChanged: (_) {},
                    ),
                  ),
              ],
            ),
            const SizedBox(height: 32),
            // Разные цвета
            Text(
              "Разные цветовые схемы",
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 16),
            Column(
              children: [
                RatingWidget(
                  rating: 3.5,
                  activeColor: Colors.red,
                  inactiveColor: Colors.pink[100]!,
                  size: 32,
                ),
                const SizedBox(height: 8),
                RatingWidget(
                  rating: 4.0,
                  activeColor: Colors.green,
                  inactiveColor: Colors.green[100]!,
                  size: 32,
                ),
                const SizedBox(height: 8),
                RatingWidget(
                  rating: 2.5,
                  activeColor: Colors.purple,
                  inactiveColor: Colors.purple[100]!,
                  size: 32,
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

5. Тесты (test/widgets/rating_widget_test.dart)

import "package:flutter/material.dart";
import "package:flutter_test/flutter_test.dart";
import "package:your_app/widgets/rating_widget.dart";

void main() {
  group("RatingWidget", () {
    testWidgets("displays correct number of stars",
        (WidgetTester tester) async {
      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            body: RatingWidget(
              rating: 3.5,
              onRatingChanged: (rating) {},
            ),
          ),
        ),
      );

      expect(find.byType(RatingWidget), findsOneWidget);
    });

    testWidgets("calls onRatingChanged when tapped",
        (WidgetTester tester) async {
      double? changedRating;

      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            body: RatingWidget(
              rating: 0,
              onRatingChanged: (rating) => changedRating = rating,
            ),
          ),
        ),
      );

      await tester.tap(find.byType(RatingWidget));
      await tester.pumpAndSettle();

      expect(changedRating, isNotNull);
    });

    testWidgets("read-only mode prevents changes",
        (WidgetTester tester) async {
      double? changedRating;

      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            body: RatingWidget(
              rating: 2.0,
              readOnly: true,
              onRatingChanged: (rating) => changedRating = rating,
            ),
          ),
        ),
      );

      await tester.tap(find.byType(RatingWidget));
      await tester.pumpAndSettle();

      expect(changedRating, isNull);
    });
  });
}

6. pubspec.yaml

dependencies:
  flutter:
    sdk: flutter

dev_dependencies:
  flutter_test:
    sdk: flutter

Ключевые особенности

  1. Половинные звёзды: Поддержка рейтинга 0.5, 1.0, 1.5 и т.д.
  2. CustomPaint: Рисование звёзд геометрически (не зависит от иконок)
  3. Анимация: ScaleAnimation при выборе рейтинга
  4. Свайп: Выбор рейтинга горизонтальным свайпом
  5. Режим чтения: Защита от редактирования
  6. Настройка: Цвета, размеры, количество звёзд
  7. Версия с иконками: Альтернатива для разных дизайнов
  8. Переиспользуемость: Работает с любыми параметрами

Это production-ready компонент для любого приложения!

Реализовать кастомный виджет рейтинга (звёзды) | PrepBro