Flutter-自定义三角形评分控件

效果图

屏幕录制2024-05-18 20.21.33.gif

序言

在移动应用开发中,显示数据的方式多种多样,直观的图形展示常常能带给用户更好的体验。本文将介绍如何使用Flutter创建一个自定义三角形纬度评分控件,该控件可以通过动画展示评分的变化,让应用界面更加生动。

实现思路及步骤

思路

  1. 定义控件属性:首先需要定义控件的基本属性,如宽度、高度、最大评分以及每个顶点的评分值。
  2. 实现动画效果:使用AnimationControllerCurvedAnimation来控制评分动画,使每个顶点的评分从0逐渐增加到对应的评分值。
  3. 自定义绘制:使用CustomPainter绘制三角形和评分三角形,并在顶点处绘制空心圆点。

步骤

  1. 创建一个TriangleRatingAnimView小部件。
  2. 定义动画控制器和动画曲线。
  3. CustomPainter中绘制三角形及评分三角形。
  4. 使用AnimatedBuilder实现动画效果。

4. 代码实现

以下是完整的代码实现:

import 'package:flutter/material.dart';

/// 三角形等级评分的控件
class TriangleRatingAnimView extends StatefulWidget {
  final double width; // 控件宽度
  final double height; // 控件高度
  final int maxRating; // 最大评分
  final int upRating; // 上顶点评分
  final int leftRating; // 左顶点评分
  final int rightRating; // 右顶点评分
  final Color strokeColor; // 三角形边框颜色
  final double strokeWidth; // 三角形边框宽度
  final Color ratingStrokeColor; // 评分三角形边框颜色
  final double ratingStrokeWidth; // 评分三角形边框宽度

  const TriangleRatingAnimView({
    Key? key,
    required this.width,
    required this.height,
    this.maxRating = 5,
    this.upRating = 0,
    this.leftRating = 0,
    this.rightRating = 0,
    this.strokeColor = Colors.grey,
    this.strokeWidth = 1,
    this.ratingStrokeColor = Colors.red,
    this.ratingStrokeWidth = 2,
  }) : super(key: key);

  @override
  TriangleRatingAnimViewState createState() => TriangleRatingAnimViewState();
}

class TriangleRatingAnimViewState extends State<TriangleRatingAnimView>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );
    _animation = CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    );
    _startAnimations();
  }

  @override
  void didUpdateWidget(TriangleRatingAnimView oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.upRating != widget.upRating ||
        oldWidget.leftRating != widget.leftRating ||
        oldWidget.rightRating != widget.rightRating) {
      _startAnimations();
    }
  }

  void _startAnimations() {
    _controller.reset();
    _controller.forward();
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return CustomPaint(
          size: Size(widget.width, widget.height),
          painter: TrianglePainter(
            upRating: (widget.upRating * _animation.value).toInt(),
            rightRating: (widget.rightRating * _animation.value).toInt(),
            leftRating: (widget.leftRating * _animation.value).toInt(),
            strokeWidth: widget.strokeWidth,
            ratingStrokeWidth: widget.ratingStrokeWidth,
            strokeColor: widget.strokeColor,
            ratingStrokeColor: widget.ratingStrokeColor,
            maxRating: widget.maxRating,
          ),
        );
      },
    );
  }
}

class TrianglePainter extends CustomPainter {
  final int maxRating;
  final int upRating;
  final int leftRating;
  final int rightRating;
  final Color strokeColor;
  final double strokeWidth;
  final Color ratingStrokeColor;
  final double ratingStrokeWidth;

  TrianglePainter({
    required this.maxRating,
    required this.upRating,
    required this.leftRating,
    required this.rightRating,
    required this.strokeWidth,
    required this.ratingStrokeWidth,
    required this.strokeColor,
    required this.ratingStrokeColor,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = strokeColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth;

    final outerPaint = Paint()
      ..color = ratingStrokeColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = ratingStrokeWidth;

    final fillPaint = Paint()
      ..color = ratingStrokeColor.withOpacity(0.3)
      ..style = PaintingStyle.fill;

    final circlePaint = Paint()
      ..color = ratingStrokeColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2.0;

    final circleFillPaint = Paint()
      ..color = Colors.white
      ..style = PaintingStyle.fill;

    // 计算三角形顶点坐标
    final p1 = Offset(size.width / 2, 0); // 顶部顶点
    final p2 = Offset(0, size.height); // 左下顶点
    final p3 = Offset(size.width, size.height); // 右下顶点

    // 绘制外部三角形
    final path = Path()
      ..moveTo(p1.dx, p1.dy)
      ..lineTo(p2.dx, p2.dy)
      ..lineTo(p3.dx, p3.dy)
      ..close();
    canvas.drawPath(path, paint);

    // 计算重心
    final centroid = Offset(
      (p1.dx + p2.dx + p3.dx) / 3,
      (p1.dy + p2.dy + p3.dy) / 3,
    );

    // 绘制顶点到重心的连线
    canvas.drawLine(p1, centroid, paint);
    canvas.drawLine(p2, centroid, paint);
    canvas.drawLine(p3, centroid, paint);

    // 根据评分计算动态顶点
    final dynamicP1 = Offset(
      centroid.dx + (p1.dx - centroid.dx) * (upRating / maxRating),
      centroid.dy + (p1.dy - centroid.dy) * (upRating / maxRating),
    );
    final dynamicP2 = Offset(
      centroid.dx + (p2.dx - centroid.dx) * (leftRating / maxRating),
      centroid.dy + (p2.dy - centroid.dy) * (leftRating / maxRating),
    );
    final dynamicP3 = Offset(
      centroid.dx + (p3.dx - centroid.dx) * (rightRating / maxRating),
      centroid.dy + (p3.dy - centroid.dy) * (rightRating / maxRating),
    );

    // 绘制内部动态三角形
    final ratingPath = Path()
      ..moveTo(dynamicP1.dx, dynamicP1.dy)
      ..lineTo(dynamicP2.dx, dynamicP2.dy)
      ..lineTo(dynamicP3.dx, dynamicP3.dy)
      ..close();
    canvas.drawPath(ratingPath, outerPaint);
    canvas.drawPath(ratingPath, fillPaint);

    // 绘制动态点上的空心圆
    const circleRadius = 5.0;
    canvas.drawCircle(dynamicP1, circleRadius, circlePaint);
    canvas.drawCircle(dynamicP1, circleRadius - 1.5, circleFillPaint); // 填充白色
    canvas.drawCircle(dynamicP2, circleRadius, circlePaint);
    canvas.drawCircle(dynamicP2, circleRadius - 1.5, circleFillPaint); // 填充白色
    canvas.drawCircle(dynamicP3, circleRadius, circlePaint);
    canvas.drawCircle(dynamicP3, circleRadius - 1.5, circleFillPaint); // 填充白色
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

使用

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_xy/xydemo/rating/rating_anim_widget.dart';

import '../../widgets/xy_app_bar.dart';

class RatingPage extends StatefulWidget {
  const RatingPage({super.key});

  @override
  State<RatingPage> createState() => _RatingPageState();
}

class _RatingPageState extends State<RatingPage> {
  var upRating = 2;
  var leftRating = 3;
  var rightRating = 5;
  var maxRating = 5;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        backgroundColor: Colors.white,
        appBar: XYAppBar(
          title: "三角形评分控件",
          onBack: () {
            Navigator.pop(context);
          },
        ),
        body: Container(
          alignment: Alignment.center,
          child: Column(
            mainAxisSize: MainAxisSize.max,
            crossAxisAlignment: CrossAxisAlignment.center,
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                "时间管理",
                style: TextStyle(
                  fontSize: 12.sp,
                ),
              ),
              SizedBox(height: 5.w),
              TriangleRatingAnimView(
                height: 200.w,
                width: 280.w,
                upRating: upRating,
                leftRating: leftRating,
                rightRating: rightRating,
                maxRating: maxRating,
                strokeWidth: 1.5.w,
                ratingStrokeWidth: 3.w,
              ),
              SizedBox(height: 5.w),
              Row(
                children: [
                  SizedBox(width: 10.w),
                  Text(
                    "成本控制",
                    style: TextStyle(
                      fontSize: 12.sp,
                    ),
                  ),
                  const Expanded(child: SizedBox.shrink()),
                  Text(
                    "质量保证",
                    style: TextStyle(
                      fontSize: 12.sp,
                    ),
                  ),
                  SizedBox(width: 10.w),
                ],
              ),
              SizedBox(height: 50.w),
              ElevatedButton(
                onPressed: () {
                  updateRatingData();
                },
                child: const Text("更改数据"),
              )
            ],
          ),
        ));
  }

  /// 更新星数指标数据
  void updateRatingData() {
    final random = Random();
    maxRating = 5 + random.nextInt(6);
    upRating = 1 + random.nextInt(maxRating);
    leftRating = 1 + random.nextInt(maxRating);
    rightRating = 1 + random.nextInt(maxRating);
    setState(() {});
  }
}

通过以上步骤和代码,我们可以创建一个带动画效果的三角形纬度评分控件,使评分展示更加生动和直观。

?著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,029评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,238评论 3 388
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事?!?“怎么了?”我有些...
    开封第一讲书人阅读 159,576评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,214评论 1 287
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,324评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,392评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,416评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,196评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,631评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,919评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,090评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,767评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,410评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,090评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,328评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,952评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,979评论 2 351

推荐阅读更多精彩内容