← Назад к вопросам
Реализовать кастомный виджет рейтинга (звёзды)
1.0 Junior🔥 181 комментариев
#Flutter виджеты#Анимации
Условие
Создайте переиспользуемый виджет рейтинга со звёздами.
Требования
- Отображение рейтинга от 0 до 5 звёзд
- Поддержка половинных звёзд (0.5)
- Возможность выбора рейтинга нажатием
- Настраиваемые параметры: размер, цвет, количество звёзд
Интерфейс виджета
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
Ключевые особенности
- Половинные звёзды: Поддержка рейтинга 0.5, 1.0, 1.5 и т.д.
- CustomPaint: Рисование звёзд геометрически (не зависит от иконок)
- Анимация: ScaleAnimation при выборе рейтинга
- Свайп: Выбор рейтинга горизонтальным свайпом
- Режим чтения: Защита от редактирования
- Настройка: Цвета, размеры, количество звёзд
- Версия с иконками: Альтернатива для разных дизайнов
- Переиспользуемость: Работает с любыми параметрами
Это production-ready компонент для любого приложения!