2022年2月13日 星期日

實戰Flutter程式開發 - Roulette Wheel Selection

        輪盤賭選擇 ( Roulette Wheel Selection ) 策略是最基本的選擇策略之一,在群體中的個體被選中的概率與個體相應的適應度函式的值成正比,且此選擇方式常出現在賭場,而此範例程式主要是藉由下列三個部份來實作類似輪盤賭選擇 ( Roulette Wheel Selection )的功能。

  1.   繪製輪盤與指針畫面   
  2.   建立隨機的指針旋轉動畫                       
  3.   動態顯示選擇結果


1.   繪製輪盤與指針畫面

        主要是藉由繼承 (extends) CustomePainter 類別建立新的類別並透過 Canvas 內部封裝繪製圖形的 API 分別建立指針與輪盤組件。


1-1.  建立指針組件      

        在繪製指針前先使用畫布 (Canvas) 所提供的旋轉 (rotate) API 調整角度後;接著再使用 Path 類別來繪製指針的形狀,由於指針底部是圓弧的形狀因此在使用畫弧 (arcTo) API 時需要建立矩形 (Rect)物件用來定義所畫的圓弧範圍並使用 Paint 類別將繪製指針進行漸層式的顏色渲染。

class DrawPointer extends CustomPainter {
  ...
  @override
  void paint(Canvas canvas, Size size) {

    // 1. Set the starting position of the drawing graphics on the canvas.
    centerOffst = Offset(size.width / 2, size.height / 2);
    canvas.translate(centerOffst.dx, centerOffst.dy);

    // 2. The angle by which the pointer is rotated before drawing.     canvas.rotate(getRadianFromAngle(angle));

    // 3. Crate a rectangle object to define the range of the drawing arc.
    centerSize = Size(size.width / 10, size.height / 5);
    var rect = Rect.fromLTWH(0 - centerSize.width / 2, 0 - centerSize.width / 2, centerSize.width, centerSize.width);
  
    // 4. Use Path object and Paint object to draw the shape and color of pointer
    canvas.drawPath(
      Path()
        ..arcTo(rect, getRadianFromAngle(30), getRadianFromAngle(300), false)
        ..relativeLineTo(centerSize.height, centerSize.width / 30)
        ..relativeLineTo(0, - 10 * centerSize.width / 30)
        ..relativeLineTo( centerSize.width, 17 * centerSize.width / 30)
        ..relativeLineTo(-centerSize.width, 17 * centerSize.width / 30 )
        ..relativeLineTo(0, -10 * centerSize.width / 30)
        ..close(),
      Paint()
        ..isAntiAlias = true
        ..style = PaintingStyle.fill
        ..shader = LinearGradient(
          begin: Alignment.bottomRight,
          end: Alignment.topLeft,
          colors: [Colors.blueGrey.withAlpha(200), Colors.cyan.withAlpha(200)],
          tileMode: TileMode.mirror,
        ).createShader(rect),
    );
    canvas.drawCircle(Offset.zero,
          centerSize.width * 0.2,
          Paint()
            ..color=Colors.black
            ..style = PaintingStyle.fill
        );

    canvas.drawCircle(Offset.zero,
          centerSize.width * 0.15,
          Paint()
            ..color=Colors.red
            ..style = PaintingStyle.fill
        );   
  }
}










1-2.  建立輪盤組件 

1-2.1.  根據項目比例繪製扇形圖  

        在繪製輪盤前先使用建立矩形 (Rect)物件用來定義所畫圓的範圍與畫布(Canvas) 中的位置後再依據每個項目比例使用畫布 (Canvas) 所提供繪製圓弧 (drawArc) 的API繪製等比例的扇形使用 Paint 類別進行項目顏色的渲染;另外為了將各項目名稱放置相對應扇形圖的中間位置,因此在繪製扇形圖時利用串列 (List) 類別來儲存繪製扇形的起始弧度與掃角弧度。

void drawRouletteWheel(Canvas canvas, Size size) {

  double startAngle = 0, sweepAngle = 0;

  // 1. Create a rectagle object to define the range and positio of the drawing pie chart.
  Rect rect = Offset(wheelRectStartPointX, wheelRectStartPointY) & Size(diameter, diameter);
  for(int repeat = 0; repeat < itemRepeat; repeat++){
    itemPercent.keys.toList().forEach((element) {
      defaultPaint.color = itemColor.containsKey(element) ? itemColor[element]!:Colors.white;

      // 2. Draw an equal-scale pie chart according to the project scale.
      sweepAngle = 360 * itemPercent[element]! / itemRepeat;
      canvas.drawArc(rect, getRadianFromAngle(startAngle), getRadianFromAngle(sweepAngle), true, defaultPaint);
      // 3. Use List class to store the start and the end radian of each sector.
      disPercent.add(WheelTextPosition(
        title:element,
        startRadian:getRadianFromAngle(startAngle),
        endRadian:getRadianFromAngle(sweepAngle)
      )); 
      startAngle = startAngle + sweepAngle;
     
    });
  }
}









 

1-2.2. 根據扇形的位置繪製項目名稱

        由於每個項目文字列印長度與相應扇形的弧長並非相同,因此根據計算項目文字所需的弧長與相應扇形圖的弧長之間的差值來調整項目字串列印的起始位置與角度後,再依序將項目文字中的每個字元依序列印到指定的位置中;另外文字的起始角度為90度,因此在計算列印文字的弧度後需要再加上90度的弧度才是實際字元列印的位置與角度。

void drawWheelText(Canvas canvas, List<WheelTextPosition> disText){
  
  // 1. Set the starting position of the title at an angle of 0 degrees.
  canvas.translate(wheelRectStartPointX + radius , wheelRectStartPointY + radius);

  double radian = 0.0;
  for (var element in disText) {

    // 2. calculate the starting radian of the title on the side of the wheel
    radian = calculateStartRadianForString(element);

    // 3. Add the calculated radian by 90 degrees
    radian += textRotateRadian;

    for (int i = 0; i< element.title.length; i++){
      canvas.save();
      
      // 4. Rotate each character of the title in order according to the starting radian.
      radian = drawWheelCharWithAngle(canvas, element.title[i], radian, lastradian );
     
      canvas.restore();
    }
  }
}

 

       藉由 TextPainter 類別取得列印字串所需的長度並除以半徑得到 sin θ後再使用 asin 函式轉換成弧度。

double calculateStartRadianForString( WheelTextPosition whellText ) {
  // 1. Generate title length.
  var _textPainter = TextPainter(
        textDirection: TextDirection.ltr,
        text: TextSpan(text: whellText.title, style:textStyle,),
      );
  _textPainter.layout(minWidth: 0, maxWidth: double.maxFinite);
  // 2. Caculate arc length based on ratio of title to raidus.
  double stringWidthForRadian = math.asin( _textPainter.width / raduis);
  return whellText.startRadian + (whellText.sweepRadian - stringWidthForRadian) / 2 ;
}

 

        藉由 TextPainter 類別取得列印字元所需的高度並加上半徑後作為列印字元的長度並搭配 sin 和cos 函式計算其列印字元的座標。另外使用TextPainter 類別取得列印字串所需的長度並除以半徑得到 sin θ 後再使用 asin 函式轉換成弧度。

double drawWheelCharWithAngle(Canvas canvas, String disChar, double curRadian){
 
  // 1. Generate character length.
  var _textPainter = TextPainter(
    textDirection: TextDirection.ltr,
    text: TextSpan(text: disChar, style:textStyle,),
  );
  _textPainter.layout(minWidth: 0, maxWidth: double.maxFinite);

  // 2. Calculate character print position.
  double radiusWithHeight = radius +_textPainter.height;
  double shiftPointX = radiusWithHeight * math.sin(curRadian); // sin(90):1
  double shiftPointY = radiusWithHeight * math.cos(curRadian) ; // cos(90):0

  // 3. adjust the print starting position of the canvas.
  canvas.translate(shiftPointX, -shiftPointY);

  // 4. Caculate radian based on ratio of character to raidus.
  curRadian = (curRadian + math.asin( _textPainter.width / radius));
  
  // 5. Rotate the canvas by arc
  canvas.rotate(curRadian);

  _textPainter.paint(canvas, Offset.zero);
  return curRadian;

}










2.  建立隨機的指針旋轉動畫   

        為了讓指針組件能夠產生旋轉的動畫,因此需要建立可變組件 (State fullWidget) 並混入 (with) TickerProviderStateMixin 類別來建立包含計時器 (TickerProvider) 的可變狀態組件並運用 AnimationController 類別進行動畫控制及使用 CurvedAnimation 與 Tween 類別來定義旋轉動畫的行為;最後使用 Gesture Detector 類別來接收使用者點擊畫面中的位置並判斷點擊位置是否在指針的圓弧位置。

class _WheelPointerState extends State<WheelPointerWidget> with TickerProviderStateMixin {
  Animation<double>? _animation;
  AnimationController? _controller;
  Tween<double>? _rotationTween;
  int pos = 0;
  bool isSelection = false;
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(vsync: this, duration: widget.duration);
    _animation = CurvedAnimation(parent: _controller!, curve: Curves.fastLinearToSlowEaseIn);
    _rotationTween = Tween<double>(begin: 0, end: 0);
    _animation = _rotationTween!.animate(_animation!)
      ..addListener(() {
        setState(() {});
      })
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed){
          isSelection = false;          
          // 1. Notify of selected radians when animation is done.
          widget.notifyCallbackFunc(isSelection, getRadianFromAngle(pos.toDouble()));
      }
    });
  }
  @override
  void didUpdateWidget (WheelPointerWidget oldWidget){
    super.didUpdateWidget(oldWidget);
    _controller!.duration = widget.duration;
  }

  @override
  void dispose() {
    _controller!.dispose();
    super.dispose();
  }
  void startAnimation() {
    isSelection = true;
    widget.notifyCallbackFunc(isSelection, 0);
    _controller!.reset();
    // 2. Set the last end of angle as the current starting angle.
    _rotationTween!.begin = pos.toDouble();
    // 3. Generate a random angle from 0 to 360 degrees.
    pos = math.Random().nextInt(360);
    // 4. Multiply the preset number of rotations by 360 plus a random value.
    _rotationTween!.end = widget.numberOfRevolutions * 360.0 + pos;
    // 5. Starts running this animation forward
    _controller!.forward();
  }

  @override
  Widget build(BuildContext context) {
    var drawPointer = DrawPointer(_animation!.value);
    return GestureDetector(
      child: CustomPaint(
        size: widget.canvasSize,
        painter: drawPointer
      ),
      onTapDown: (tapDownDetails) {
        // 6. if the animations has been completed and determine the click position.
        if(!isSelection && drawPointer.isclick(tapDownDetails.localPosition)){
          startAnimation();
        }
      },
    );
  }
}




































3.  動態顯示選擇結果

        最後在指針動畫結束時需要依據指針的角度換算成弧度並以此確認選擇項目後顯示項目名稱,但由於完成上述行為需要將各組件態進行跨組件因此可使用 provider 套件來簡化實作流程。


3-1.  建立跨組件的共享數據類別

       首先建立一個可保存跨組件的共享數據類別,此類別是藉由繼承 (extends) InheritedWidget 類別而建立的 ShareDataInheritedWidget 類別,目的是實踐一個可由上至下的共享數據物件且由於具體保存數據類型不可預期,為了通用性則使用泛型的方式來定義此類別。

// 1. Build a generic InheritedWidget to hold state that needs to be shared across components.
class ShareDataInheritedWidget<T> extends InheritedWidget {
  const ShareDataInheritedWidget({
        Key? key,
        required this.prvObj,
        required Widget child
  }) : super(key: key, child: child);

  final T prvObj;

  // 2. Define a convenience method to facilitate the widgets in the subtree to obtain shared data.
  static ShareDataInheritedWidget? of<T>(BuildContext context){
    final ShareDataInheritedWidget? inheritedObj = context.dependOnInheritedWidgetOfExactType<ShareDataInheritedWidget<T>>();
    assert(inheritedObj != null, "No this object found in context!");
    return inheritedObj;
  }

  @override
  bool updateShouldNotify(ShareDataInheritedWidget<T> oldWidget) {
    // 3. This callback determines whether to notify the datadependent widgets in the subtree to rebuild when the data changes.
    return prvObj != oldWidget.prvObj;
  }
}

 

3-2.  建立跨組件的事件通知類別

        由於數據發生變化的時候需要重新建構 ShareDataInherited Widget 類別因此需要建立一個共享狀態的類別,此類別是藉由繼承 (extends) ChangeNotifier 類別而建立的 ChangeDataNotifier 類別,其目的是在共享狀態變更時能使用notifyListeners函數來通知註冊此事件的 ShareDataInherited Widget 類別進而實踐跨組件 (Widget) 的事件通知行為且由於具體回傳函數類型不可預期,為了通用性則使用泛型的方式來定義此類別。 

class ChangeDataNotify<T> extends ChangeNotifier {  
  ChangeDataNotify(this.hookData);
  late T hookData;

  void changeDataFunc(T changeData ){
    hookData = changeData;
    // 1. Notify the listeners to rebuild the update state of the InheritedWidget.
    notifyListeners();
  }

  T getDataFunc(){
    return hookData;
  }
}

 

3-3.  建立 RouletteWheelWidget 組件

        使用前述的類別分別建立 DisplayTitleWidget 組件並搭配使用Consumer 組件用來即時顯示選擇項目和藉由整合輪盤組件和指針組件並搭配使用 provider 套件中的Provider.of 組件用來實現選擇項目更新的即時通知,最後整合建立 RouletteWheelWidget 組件。

class DisplayTitleWidget extends StatelessWidget {
  const DisplayTitleWidget ({Key? key}):super(key: key);
  @override
  Widget build(BuildContext context) {
    // 1. ChangeDataNotify is provided to widgets in our app through the ChangeNotifierProvider declaration at the top.
    return Consumer<ChangeDataNotify<String>>(
      builder: (context, obj, child) {
        // 2. Use ChangeDataNotify widget here, without rebuilding every time.
        return Text(obj.getDataFunc(),
          style: const TextStyle(
            color: Colors.brown,
            fontSize: 24,
            fontWeight: FontWeight.normal,
            fontStyle: FontStyle.normal,
            decoration: TextDecoration.none,
          ),
        );
      }
    );
  }
}
class RouletteWheelWidget extends StatefulWidget {
  const RouletteWheelWidget({
    Key? key,
    required this.radius,
    required this.textStyle,
    required this.itemRepeat,
    required this.itemPercent,
    required this.itemColor,
  }) : super(key: key);

  final double radius;
  final TextStyle textStyle;
  final int itemRepeat;
  final Map<String, double> itemPercent;
  final Map<String, Color> itemColor;

  @override
  State<RouletteWheelWidget> createState() => _RouletteWheelState();
}
class _RouletteWheelState extends State<RouletteWheelWidget> {
  // 1. Create uniquely identify elements for access by other object associated with elements.
  final GlobalKey <WheelWidgetState> _wheelWidgetKey = GlobalKey();
  WheelWidget? wheelWidget;
  String title = "未知";

  void notifyResultFunc(bool isSelection ,double radian) {
    title = isSelection ? "選擇中" : _wheelWidgetKey.currentState!.getTitleFromRadian(radian);
    // 2. Use Provider.of with the listen parameter set to false without rebuild ChangeDataNotify<String> wedget.
    Provider.of<ChangeDataNotify<String>>(context, listen: false).changeDataFunc(title);
  }

  @override
  Widget build(BuildContext context) {
    wheelWidget ??= WheelWidget (
          key: _wheelWidgetKey,
          radius: widget.radius,
          canvasSize: Size(widget.radius*2, widget.radius*2),
          itemRepeat: widget.itemRepeat,
          itemPercent: widget.itemPercent,
          itemColor: widget.itemColor,
          textStyle: widget.textStyle,
    );

    return Column(
          mainAxisAlignment: MainAxisAlignment.center,
          mainAxisSize: MainAxisSize.min,
          children:[
            // 3. Use ShareDataInheritedWidget<String> to share title with DisplayTitleWidget widget.
            ShareDataInheritedWidget<String>(
              prvObj: title,
              child: const DisplayTitleWidget(),
            ),
            SizedBox(
              height: widget.textStyle.fontSize! * 2,
            ),
            Stack (
              children:[
                wheelWidget!,
                WheelPointerWidget(
                  canvasSize: Size(widget.radius * 2, widget.radius * 2),
                  numberOfRevolutions: 3,
                  duration: const Duration(seconds: 7),
                  notifyCallbackFunc:notifyResultFunc,
              ]
          ),
        ]
    );
  }
}





4.  各平台的執行結果

        整合使用 provider 套件中的 ChangeNotifierProvider 組件將RouletteWheelWidget 組件與ChangeDataNotify 組件作為參數後建立RoulettWheelSelectionApp 組件在各不同的平台進行實測。

void main() => runApp(const RoulettWheelSelectionApp());

class RoulettWheelSelectionApp extends StatelessWidget {
  const RoulettWheelSelectionApp({Key? key}):super(key: key);
  @override
  Widget build (BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          // 1. Use ChangeNotifierProvider widget of provider package to provides an instance of a ChangeNotifier.
          child: ChangeNotifierProvider(
            // 2. Create ChangeDataNotify class to keep private state.
            create: (context) => ChangeDataNotify<String>("未選擇"),
            // 3. Initialize RouletteWheelWidget class
            child:const RouletteWheelWidget(
              radius: 150,
              textStyle: TextStyle(
                fontSize: 20,
                fontWeight: FontWeight.bold,
                color: Colors.black45
              ),
              itemRepeat: 2,
              itemPercent: {"第一項":0.4, "第二項":0.3,"第三項": 0.2, "第四項":0.1},
              itemColor: {"第一項":Colors.green, "第二項":Colors.yellow ,"第三項": Colors.red, "第四項":Colors.brown},
            ),
          ),
        ),
      ),
    );
  }
}

4-1.  Windows Desktop



 

4-2.  Chrome Web





4-3.  Linux APP (Ubuntu)





4-4.  Android APP