實戰Flutter程式開發 - Roulette Wheel Selection

更新於 發佈於 閱讀時間約 40 分鐘
  輪盤賭選擇 ( 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
        );   
  }
}
raw-image

  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;
     
    });
  }
}
raw-image

    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;

}
raw-image


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();
        }
      },
    );
  }
}
raw-image
raw-image


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,
              ]
          ),
        ]
    );
  }
}
raw-image
raw-image


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

raw-image
raw-image
raw-image

  4-2.  Chrome Web

raw-image
raw-image
raw-image

  4-3.  Linux APP (Ubuntu)

raw-image
raw-image
raw-image

  4-4.  Android APP

raw-image
raw-image
raw-image
GitHub : https://github.com/crosscode-software/roulette_wheel_selection
Pub.dev : https://pub.dev/packages/roulette_wheel_selection


留言
avatar-img
留言分享你的想法!
avatar-img
跨碼軟體有限公司的沙龍
3會員
9內容數
2022/04/22
  雖然Dart 語言本身支援跨平台的編譯方式,但在實務開發時還是不免需要使用外部非Dart語言所提供的函式庫進行功能開發且由於C 語言是最為廣泛且通用的程式語言,因此Dart語言也有提供支援與C語言函式庫互通性的方式;本篇主要是以MSVC作為C的編譯器來實作說明如何引用C語言會遇到的作法。
Thumbnail
2022/04/22
  雖然Dart 語言本身支援跨平台的編譯方式,但在實務開發時還是不免需要使用外部非Dart語言所提供的函式庫進行功能開發且由於C 語言是最為廣泛且通用的程式語言,因此Dart語言也有提供支援與C語言函式庫互通性的方式;本篇主要是以MSVC作為C的編譯器來實作說明如何引用C語言會遇到的作法。
Thumbnail
2022/01/24
說明Flutter 模組(Module)專案範例的架構與如何載入Android專案中的流程與執行畫面
Thumbnail
2022/01/24
說明Flutter 模組(Module)專案範例的架構與如何載入Android專案中的流程與執行畫面
Thumbnail
2022/01/18
說明Flutter 插件(Plugin)專案範例的架構與實際載入並執行在各平台的顯示畫面
Thumbnail
2022/01/18
說明Flutter 插件(Plugin)專案範例的架構與實際載入並執行在各平台的顯示畫面
Thumbnail
看更多
你可能也想看
Thumbnail
「欸!這是在哪裡買的?求連結 🥺」 誰叫你太有品味,一發就讓大家跟著剁手手? 讓你回購再回購的生活好物,是時候該介紹出場了吧! 「開箱你的美好生活」現正召喚各路好物的開箱使者 🤩
Thumbnail
「欸!這是在哪裡買的?求連結 🥺」 誰叫你太有品味,一發就讓大家跟著剁手手? 讓你回購再回購的生活好物,是時候該介紹出場了吧! 「開箱你的美好生活」現正召喚各路好物的開箱使者 🤩
Thumbnail
建立幾個變數如下,最上面兩個變數值為清單值 接下來分別設定球1位置到左上角落、設定球2位置到右上角落、設定球3位置到左下角落、設定球4位置到右下角落 當螢幕初始化的時候,設定玩家球的X、Y座標和大小,並將玩家球的初始顏色,設定成(變數_顏色清單)中.....
Thumbnail
建立幾個變數如下,最上面兩個變數值為清單值 接下來分別設定球1位置到左上角落、設定球2位置到右上角落、設定球3位置到左下角落、設定球4位置到右下角落 當螢幕初始化的時候,設定玩家球的X、Y座標和大小,並將玩家球的初始顏色,設定成(變數_顏色清單)中.....
Thumbnail
想必每位教師都會有黏黏球sticky ball這個小教具 除了丟分數外,還有哪幾種新玩法呢? *會不定時更新腦海中冒出的新玩法唷~
Thumbnail
想必每位教師都會有黏黏球sticky ball這個小教具 除了丟分數外,還有哪幾種新玩法呢? *會不定時更新腦海中冒出的新玩法唷~
Thumbnail
入門系列篇主要講解基本規則,適合初學者,一起輕鬆學圍棋吧🌟
Thumbnail
入門系列篇主要講解基本規則,適合初學者,一起輕鬆學圍棋吧🌟
Thumbnail
本範例主要說明如何運用Flutter 繪圖與動態相關的API並搭配provider套件進行實作輪盤賭選擇 ( Roulette Wheel Selection ) 程式。
Thumbnail
本範例主要說明如何運用Flutter 繪圖與動態相關的API並搭配provider套件進行實作輪盤賭選擇 ( Roulette Wheel Selection ) 程式。
Thumbnail
這篇文章將利用之前所學過的一些東西,包括if敘述、串列、while迴圈、函數等等的觀念,來實作一個撲克牌的小遊戲-21點。
Thumbnail
這篇文章將利用之前所學過的一些東西,包括if敘述、串列、while迴圈、函數等等的觀念,來實作一個撲克牌的小遊戲-21點。
Thumbnail
  一個遊戲規則簡單,適合闔家大小遊玩的桌遊,需要滿足一些條件,就是規則夠簡單、然後趣味性要夠高,這樣就能在短時間內吸引住大家的眼球,而且有動力玩下去。   彈指賽車的規則真的是非常簡單,也一點都不複雜,跟很多桌遊比起來,說明書就是介於有跟沒有之間,因為或許直接印在遊戲盒上就行,甚至不需要翻譯成中文
Thumbnail
  一個遊戲規則簡單,適合闔家大小遊玩的桌遊,需要滿足一些條件,就是規則夠簡單、然後趣味性要夠高,這樣就能在短時間內吸引住大家的眼球,而且有動力玩下去。   彈指賽車的規則真的是非常簡單,也一點都不複雜,跟很多桌遊比起來,說明書就是介於有跟沒有之間,因為或許直接印在遊戲盒上就行,甚至不需要翻譯成中文
Thumbnail
翻閱了去年面試時候的題目,想想現在自己會用什麼方式重新完成這個題目,也正好最近在看python的typing模組及其他使用,使用物件導向的方式改寫了程式碼。
Thumbnail
翻閱了去年面試時候的題目,想想現在自己會用什麼方式重新完成這個題目,也正好最近在看python的typing模組及其他使用,使用物件導向的方式改寫了程式碼。
追蹤感興趣的內容從 Google News 追蹤更多 vocus 的最新精選內容追蹤 Google News