移动客户端FlutterFlutter

Flutter 自定义View——仿同花顺自选股列表

2020-07-15  本文已影响0人  吉哈达

前言

很久之前群里有人悬赏实现这个功能,因为较忙所以没接,趁这几天没事把它实现出来。

目标

image

布局草图

image

说明

Tip:为了避免线的重叠导致混乱,这里刻意偏离了一些像素,如果不好理解,可以对比代码。

因为没有设计图,所以开发时我划分了一个基本块(如黑色),尺寸为

宽度: 1 * quarter : 屏幕宽度/4
高度 : blockHeight : 40

根部View为一个Stack。

结构图

image

代码实现

这里的实现是按当时的开发顺序而不是Stack层级顺序

所有滚动组件的滚动处理都由咱们自行处理。

黑色区域

首先我们在根部写一个stack,然后写左上角那个最容易的 黑色方块。

              Container(
                color: Colors.white,
                alignment: Alignment.center,
                width: quarter,height: blockHeight,
                child: Text('编辑',style: TextStyle(color: Colors.black),),
              ),

蓝色区域

之后我们实现顶部的tag,这里是一个横向的listview ,代码:

              Positioned(
                left: quarter, //注意这里,要左边空出一个 quarter 避免黑色区域遮挡
                child: buildTags(),
              ),
ListView(
        controller: tagController,
        physics: NeverScrollableScrollPhysics(),
        scrollDirection: Axis.horizontal,
        children: List.generate(titles.length, (index){
          return Container(
            color: Colors.white,
            width: quarter,height: blockHeight,
            alignment: Alignment.center,
            child: Text('${titles[index]}'),
          );
        }),
      )

黄色区域

接着实现左侧黄色区域(股票代码) ,这是一个纵向的listview, 代码:

  Widget buildStockName(Size size){
    return Container(
      color: Colors.white,
      margin: EdgeInsets.only(top: blockHeight), //上方要空一个 blockheight 避免黑色遮挡
      width: quarter,height: size.height - blockHeight,
      child: ListView.builder(
        controller: stockNameController,
        physics: NeverScrollableScrollPhysics(),
        padding: EdgeInsets.all(0),
          itemCount: 50,
          itemBuilder: (ctx,index){
            return Container(
              width:quarter,height: blockHeight,
              alignment: Alignment.center,
              child: Text('No.600$index'),);
          }),
    );
  }

紫色和粉色区域

最后我们实现最底层的紫色和粉色区域,首先我们先把它俩作为一个widget看待,并写在stack的第一个位置,

代码结构如下:

Container(
          padding: MediaQuery.of(context).padding,
          color: Colors.white,
          width: size.width,height: size.height,
          child: Stack(
            children: <Widget>[
              ///粉色 紫色
              buildBottomPart(size),
              ///黑色
              Container(
                color: Colors.white,
                alignment: Alignment.center,
                width: quarter,height: blockHeight,
                child: Text('编辑',style: TextStyle(color: Colors.black),),
              ),
              ///蓝色
              Positioned(
                left: quarter,
                child: buildTags(),
              ),
              ///黄色
              buildStockName(size),


            ],
          ),
        )

粉色区域和紫色区域的总宽度是:

quarter * 4 + titles.length * quarte 

外层包裹一个SingleChildScrollView方便我们滚动处理,代码如下:

  buildBottomPart(Size size){
    return SingleChildScrollView(
      scrollDirection: Axis.horizontal,
      controller: rightController,
      physics: NeverScrollableScrollPhysics(),
      child: Container(
        margin: EdgeInsets.only(top: blockHeight),
        padding: EdgeInsets.only(left: quarter),
        width: quarter*4+titles.length*quarter,height: size.height,
        child: Row(
          children: <Widget>[
            ///紫色
            Container(
              width: miniPageWidth,height: size.height,
              color: Colors.red,
              child: buildLeftDetail(),
            ),
            ///粉色
            Container(
              width: titles.length*quarter,height: size.height,
              color: Colors.blue,
              child: buildStockDetail(),
            ),
          ],
        ),
      ),
    );
  }

紫色和粉丝内部的item很简单(折线图是我瞎画的,不要随意联想),这里不做赘述,示意图和代码如下:

紫色示意图:

image
        return Container(
          width: quarter * 3,height: blockHeight,
          child: Row(
            children: <Widget>[
              LineChart(quarter*2,blockHeight),
              Container(
                alignment: Alignment.center,
                color: Colors.lightBlueAccent,
                width: quarter,height: blockHeight,
                child: Text('分时图'),
              )
            ],
          ),
        );

粉色示意图:

image

item 代码:

        return Container(
          width: quarter * titles.length,height: blockHeight,
          child: stockDetail(index),
        );
  Widget stockDetail(int index){
    return ListView(
      //controller: detailHorController,
      physics: NeverScrollableScrollPhysics(),
      shrinkWrap: true,
      padding: EdgeInsets.all(0),
      scrollDirection: Axis.horizontal,
      children: List.generate(titles.length, (index){
        return Container(
          color: index % 2 == 0 ? Colors.yellow : Colors.purple,
          width:quarter,height: blockHeight,
          alignment: Alignment.center,
          child: Text('$index.2%'),);
      }),
    );
  }

更新蓝色区域

我们发现,紫色区域和粉色区域对应的tag是不一样的,所以我们要更新一下蓝色区域(tag)的代码,并先设置一个flag标识是紫色还是粉色显示。

bool chartShow = false //紫色区域是否显示

更新后的 tag 代码:

Widget buildTags(){
    return Container(
      width: quarter*3, height: blockHeight,
      child: chartShow ?    //注意这里, 用于切换显示
          Row(
            children: <Widget>[
              Container(width: quarter*2,height: blockHeight,alignment: Alignment.center,
                child: Text('分时图'),),
              Container(width: quarter*1,height: blockHeight,alignment: Alignment.center,
                child: Text('涨幅'),),
            ],
          )
          : ListView(
        controller: tagController,
        physics: NeverScrollableScrollPhysics(),
        scrollDirection: Axis.horizontal,
        children: List.generate(titles.length, (index){
          return Container(
            color: Colors.white,
            width: quarter,height: blockHeight,
            alignment: Alignment.center,
            child: Text('${titles[index]}'),
          );
        }),
      ),
    );
  }

手势和滑动处理

接下来就是麻烦的手势处理了,上面我们将所有的滚动widget的属性设置为不滚动,并分别跟他们传入了scrollController。
如下:

  //控制底层横向滚动
  ScrollController rightController;
  //控制右侧股票详情(粉色区域)
  ScrollController stockVerticalController;
  //控制左侧股票名称
  ScrollController stockNameController;
  //控制顶部tag 横向滚动
  ScrollController tagController;
  //控制图表页的纵向滚动(紫色区域)
  ScrollController chartController;

在这之前,我们先定义一个枚举来标识滑动方向:

enum SlideDirection{
  Left,Right,Up,Down
}

 SlideDirection slideDirection;

之后我们在根布局Stack外层包裹一个gestureDetector组件,用于手势处理,加上之前写的UI布局,整体代码如下:

GestureDetector(
        //处理手势的三个方法
        onPanStart: handleStart,
        onPanEnd: handleEnd,
        onPanUpdate: handleUpdate,
        child: Container(
          padding: MediaQuery.of(context).padding,
          color: Colors.white,
          width: size.width,height: size.height,
          child: Stack(
            children: <Widget>[
              ///bottom part
              buildBottomPart(size),
              ///left top
              Container(
                color: Colors.white,
                alignment: Alignment.center,
                width: quarter,height: blockHeight,
                child: Text('编辑',style: TextStyle(color: Colors.black),),
              ),
              ///right top detail tag
              Positioned(
                left: quarter,
                child: buildTags(),
              ),
              ///left stock name
              buildStockName(size),
            ],
          ),
        ),
      ),

三个方法我们一个一个来。

handleStart

当我们的手指第一次接触屏幕时,这个方法会被调用,只要保持手指不离屏(或者未被取消)那么,这个方法只会调用一次。

  Offset lastPos; //我们记录一下手指的位置

  handleStart(DragStartDetails details){
    lastPos = details.globalPosition;

  }

handleUpdate

当我们触摸屏幕,并开始移动的时候,这个方法变回持续性调用。这个方法有点长,我将解释写在注释里,方便阅读。

  handleUpdate(DragUpdateDetails details){
    //这里有点像android 原生了,我们先根据滑动位置来判断方向
    if((details.globalPosition.dx - lastPos.dx).abs() > (details.globalPosition.dy - lastPos.dy).abs()){
      ///横向滑动
      if(details.globalPosition.dx > lastPos.dx){
        //向右
        slideDirection = SlideDirection.Right;
      }else{
        //向左
        slideDirection = SlideDirection.Left;
      }

    }else{
      ///纵向滑动
      if(details.globalPosition.dy > lastPos.dy){
        //向下
        slideDirection = SlideDirection.Down;
      }else{
        //向上
        slideDirection = SlideDirection.Up;
      }
    }
    //之后我们记录滑动的距离,这里的滑动距离是上次点到当前点的距离,不是总距离
    double disV = (details.globalPosition.dy - lastPos.dy).abs();
    double disH = (details.globalPosition.dx - lastPos.dx).abs();
    
    //然后我们根据滑动方向来驱动哪些滑动组件
    switch(slideDirection){
      case SlideDirection.Left:
        //向左滑动时,我们要保证不能滑动超出最大尺寸(其实不做这个处理也没事它会滚回来)
        //rightController.position.maxScrollExtent是当前可滚动的最大尺寸
        if(rightController.offset < rightController.position.maxScrollExtent){
          rightController.jumpTo(rightController.offset + disH);
          if(!chartShow){
            //如果是粉色区域显示,我们才滑动顶部tag
            //因为紫色区域的tag是不需要滚动的
            tagController.jumpTo(tagController.offset + disH);
          }
        }
        break;
      case SlideDirection.Right:
        //向右滑动
        if(rightController.offset > quarter*3){
            //粉色区域显示时
          if((rightController.offset - disH) < quarter*3){
            //通过这个判断,我们要确保用户快速fling时,不会把紫色区域滑出来
            rightController.jumpTo(quarter*3);
          }else{
            //普通向右滑动
            rightController.jumpTo(rightController.offset - disH);
            //同上
            if(!chartShow){
              tagController.jumpTo(tagController.offset - disH);
            }
          }
        }else if(rightController.offset != 0 && rightController.offset <= quarter*3){
            //当用户在粉色初始区域时继续向右滑动,我们要营造一个阻尼效果(这里简单处理一下),
          rightController.jumpTo(rightController.offset - disH/3);
        }


        break;
      case SlideDirection.Up:
        //向上滑动
        if(stockVerticalController.offset < stockVerticalController.position.maxScrollExtent){
            //股票名字,粉色和紫色向上滑动
          stockVerticalController.jumpTo(stockVerticalController.offset+disV);
          stockNameController.jumpTo(stockNameController.offset+disV);
          chartController.jumpTo(stockNameController.offset+disV);
        }
        break;
      case SlideDirection.Down:
        //股票名字,粉色和紫色向下滑动
        if(stockVerticalController.offset > stockVerticalController.position.minScrollExtent){
          stockVerticalController.jumpTo(stockVerticalController.offset-disV);
          stockNameController.jumpTo(stockNameController.offset-disV);
          chartController.jumpTo(stockNameController.offset-disV);
        }
        break;
    }
    //记录一下当前滑动位置
    lastPos = details.globalPosition;
  }

handleEnd

当我们滑动后,手指离屏时,会调用且只调用一次这个方法,这是我们就需要对粉色和紫色的显隐做处理(回弹效果),代码如下:

  bool rightAnimated = false;//滚动动画是否运行
  bool chartShow = false;//紫色区域是否显示

  handleEnd(DragEndDetails details){
    //首先我们只处理横向滚动
    if(slideDirection == SlideDirection.Left || slideDirection == SlideDirection.Right){
        //紫色和粉色的切换是需要动画来做的,在这之前,我们要确保上一次的动画完成,
      if(!rightAnimated &&rightController.offset != 0 && rightController.offset < quarter *3){
        if((quarter*3 - rightController.offset) > quarter/2 && details.velocity.pixelsPerSecond.dx > 500){
            //当用户滚动时,紫色区域显示出的宽度大于 quarter/2,切用户滑动速度大于 500时,我们就要做切换了
            //details.velocity.pixelsPerSecond.dx 横向 像素/每秒
          rightAnimated = true;
          rightController.animateTo(0.0, duration: Duration(milliseconds: 300), curve: Curves.ease)
              .then((value){
            rightAnimated = false;
            setState(() {
              chartShow = true;
            });
          });
        }else{
            //如果不符合上面的条件,我们再滚回粉色显示区域
          rightAnimated = true;
          rightController.animateTo(quarter*3, duration: Duration(milliseconds: 50), curve: Curves.ease)
              .then((value){
            rightAnimated = false;
            setState(() {
              chartShow = false;
            });
          });
        }
      }
    }
  }

至此我们整个功能就实现了,实际上还有很多可以优化的地方,这里就交给大家探索一下吧。

如果觉得对你有帮助,就点个赞和star吧 ~ 谢谢  :)

DEMO

Demo

推荐

Bedrock——基于MVVM+Provider的Flutter快速开发框架

Flutter——PageView的PageController源码分析笔记

Flutter—Android混合开发之下载安装的实现

上一篇下一篇

猜你喜欢

热点阅读