Flutter侧边栏控件-SideBar

前言

SideBar是APP开发当中常见的功能之一,多用于索引列表,如城市选择,分类等。在优化OpenGit趋势列表时,由于在选择语言时需要用到这样的控件,尝试开发了这个控件,效果如下图所示

image

准备

完成SideBar需要向外提供以下参数

  1. SideBar宽以及每个letter的高度;
  2. 默认背景色和文本颜色;
  3. 按下时的背景色和文本颜色;
  4. 当前选中letter的回调;
  5. 索引列表;

选中letter的回掉函数如下所示

typedef OnTouchingLetterChanged = void Function(String letter);

索引列表数据如下面代码所示

const List<String> A_Z_LIST = const [
  "A",
  "B",
  "C",
  "D",
  "E",
  "F",
  "G",
  "H",
  "I",
  "J",
  "K",
  "L",
  "M",
  "N",
  "O",
  "P",
  "Q",
  "R",
  "S",
  "T",
  "U",
  "V",
  "W",
  "X",
  "Y",
  "Z",
  "#"
];

当按下SideBar时需要刷新UI,所以SideBar需要继承StatefulWidget,构造函数如下所示

class SideBar extends StatefulWidget {
  SideBar({
    Key key,
    @required this.onTouch,
    this.width = 30,
    this.letterHeight = 16,
    this.color = Colors.transparent,
    this.textStyle = const TextStyle(
      fontSize: 12.0,
      color: Color(YZColors.subTextColor),
    ),
    this.touchDownColor = const Color(0x40E0E0E0),
    this.touchDownTextStyle = const TextStyle(
      fontSize: 12.0,
      color: Color(YZColors.mainTextColor),
    ),
  });

  final int width;

  final int letterHeight;

  final Color color;

  final Color touchDownColor;

  final TextStyle textStyle;

  final TextStyle touchDownTextStyle;

  final OnTouchingLetterChanged onTouch;
}

封装SideBar

_SideBarState中,需要通过touch的状态来判断背景色的展示,相关代码如下所示

class _SideBarState extends State<SideBar> {
  bool _isTouchDown = false;

  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: Alignment.center,
      color: _isTouchDown ? widget.touchDownColor : widget.color,
      width: widget.width.toDouble(),
      child: _SlideItemBar(
        letterWidth: widget.width,
        letterHeight: widget.letterHeight,
        textStyle: _isTouchDown ? widget.touchDownTextStyle : widget.textStyle,
        onTouch: (letter) {
          if (widget.onTouch != null) {
            setState(() {
              _isTouchDown = !TextUtil.isEmpty(letter);
            });
            widget.onTouch(letter);
          }
        },
      ),
    );
  }
}

上面代码,主要部分通过_SlideItemBar的letter状态的改变来刷新Container的color,下面看下_SlideItemBar的实现,相关代码如下所示

class _SlideItemBar extends StatefulWidget {
  final int letterWidth;

  final int letterHeight;

  final TextStyle textStyle;

  final OnTouchingLetterChanged onTouch;

  _SlideItemBar(
      {Key key,
      @required this.onTouch,
      this.letterWidth = 30,
      this.letterHeight = 16,
      this.textStyle})
      : assert(onTouch != null),
        super(key: key);

  @override
  _SlideItemBarState createState() {
    return _SlideItemBarState();
  }
}

上文代码,没有做过多的操作,只是定义了几个变量,详细的操作在_SlideItemBarState
_SlideItemBarState中,需要知道每个letter在垂直方向上的偏移高度,如下面代码所示

void _init() {
    _letterPositionList.clear();
    _letterPositionList.add(0);
    int tempHeight = 0;
    A_Z_LIST?.forEach((value) {
      tempHeight = tempHeight + widget.letterHeight;
      _letterPositionList.add(tempHeight);
    });
}

填充每个letter widget,并设置固定宽高,代码如下所示

List<Widget> children = List();
A_Z_LIST.forEach((v) {
    children.add(SizedBox(
        width: widget.letterWidth.toDouble(),
        height: widget.letterHeight.toDouble(),
        child: Text(v, textAlign: TextAlign.center, style: _style),
    ));
});

在滑动SideBar过程中,需要检测手势事件,代码如下所示

GestureDetector(
      onVerticalDragDown: (DragDownDetails details) {
        //计算索引列表距离顶部的距离
        if (_widgetTop == -1) {
          RenderBox box = context.findRenderObject();
          Offset topLeftPosition = box.localToGlobal(Offset.zero);
          _widgetTop = topLeftPosition.dy.toInt();
        }
        //获取touch点在索引列表的偏移值
        int offset = details.globalPosition.dy.toInt() - _widgetTop;
        int index = _getIndex(offset);
        //判断索引是否在列表中,如果存在,则通知上层更新数据
        if (index != -1) {
          _lastIndex = index;
          _triggerTouchEvent(A_Z_LIST[index]);
        }
      },
      onVerticalDragUpdate: (DragUpdateDetails details) {
        //获取touch点在索引列表的偏移值
        int offset = details.globalPosition.dy.toInt() - _widgetTop;
        int index = _getIndex(offset);
        //并且前后两次的是否一致,如果不一致,则通知上层更新数据
        if (index != -1 && _lastIndex != index) {
          _lastIndex = index;
          _triggerTouchEvent(A_Z_LIST[index]);
        }
      },
      onVerticalDragEnd: (DragEndDetails details) {
        _lastIndex = -1;
        _triggerTouchEvent('');
      },
      onTapUp: (TapUpDetails details) {
        _lastIndex = -1;
        _triggerTouchEvent('');
      },
      //填充UI
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: children,
      ),
    )

上文代码,在onVerticalDragDown事件时,首次获取索引距离顶部的高度,并通过touch点的y坐标获取到touch点在偏移值,并通过该值找到目前touch的索引,记录该状态,并通知上层ui;在onVerticalDragUpdate事件时,获取索引跟onVerticalDragDown一致,只是多了出重的操作。而onVerticalDragEndonTapUp代表touch事件的结束。到此,可以获取到触摸SideBar时,回掉的letter数据。

展示letter

SideBar通常展示在ListView的上面,父容器我们采用Stack,如下面代码所示

@override
Widget build(BuildContext context) {
    super.build(context);

    return Scaffold(
      body: Stack(
        children: <Widget>[
          _buildSideBar(context),
          _buildLetterTips(),
        ],
      ),
    );
}

Widget _buildSideBar(BuildContext context) {
    return Offstage(
      offstage: widget.offsetBuilder == null,
      child: Align(
        alignment: Alignment.centerRight,
        child: SideBar(
          onTouch: (letter) {
            setState(() {
              _letter = letter;
            });
          },
        ),
      ),
    );
}

Widget _buildLetterTips() {
    return Offstage(
      offstage: TextUtil.isEmpty(_letter),
      child: Align(
        alignment: Alignment.center,
        child: Container(
          alignment: Alignment.center,
          width: 65.0,
          height: 65.0,
          color: Color(0x40000000),
          child: Text(
            TextUtil.isEmpty(_letter) ? '' : _letter,
            style: YZConstant.largeLargeTextWhite,
          ),
        ),
      ),
    );
}

当接收到letter发生改变时,会通过setState刷新ui,当_letter不为空时,就会展示当前letter的提示。

滚动ListView

滚动ListView目前只发现两种方法,如下面代码所示

//带动画的滚动
scrollController.animateTo(double offset);
//不带动画的滚动
scrollController.jumpTo(double offset);

由于上述两种方法都需要知道滚动的具体位置,所以需要知道ListView列表的每个item相对于屏幕顶部的偏移量,所以高度必须是固定的。

获取语言列表数据

调用接口https://github-trending-api.now.sh/languages,并封装bean对象,如下面代码所示

List<TrendingLanguageBean> getTrendingLanguageBeanList(List<dynamic> list) {
  List<TrendingLanguageBean> result = [];
  list.forEach((item) {
    result.add(TrendingLanguageBean.fromJson(item));
  });
  return result;
}

@JsonSerializable()
class TrendingLanguageBean extends Object {
  @JsonKey(name: 'id')
  String id;

  @JsonKey(name: 'name')
  String name;

  String letter;

  bool isShowLetter;

  TrendingLanguageBean(this.id, this.name, {this.letter});

  factory TrendingLanguageBean.fromJson(Map<String, dynamic> srcJson) =>
      _$TrendingLanguageBeanFromJson(srcJson);

  Map<String, dynamic> toJson() => _$TrendingLanguageBeanToJson(this);
}

对获取到的数据进行排序,如下面代码所示

void _sortListByLetter(List<TrendingLanguageBean> list) {
    if (list == null || list.isEmpty) return;
    list.sort(
      (a, b) {
        if (a.letter == "@" || b.letter == "#") {
          return -1;
        } else if (a.letter == "#" || b.letter == "@") {
          return 1;
        } else {
          return a.letter.compareTo(b.letter);
        }
      },
    );
}

通过语言对应的首字母,设置其展示状态,如下面代码所示

void _setShowLetter(List<TrendingLanguageBean> list) {
    if (list != null && list.isNotEmpty) {
      String tempLetter;
      for (int i = 0, length = list.length; i < length; i++) {
        TrendingLanguageBean bean = list[i];
        String letter = bean.letter;
        if (tempLetter != letter) {
          tempLetter = letter;
          bean.isShowLetter = true;
        } else {
          bean.isShowLetter = false;
        }
      }
    }
}

列表数据已经准备完毕,初始化单个item的高度,如下面代码所示

double getLetterHeight() => 48.0;

double getItemHeight() => 56.0;

然后进一步计算每个letter在ListView中所处的高度,如下面代码所示

void _initListOffset(List<TrendingLanguageBean> list) {
    _letterOffsetMap.clear();
    double offset = 0;
    String letter;
    list?.forEach((v) {
      if (letter != v.letter) {
        letter = v.letter;
        _letterOffsetMap.putIfAbsent(letter, () => offset);
        offset = offset + getLetterHeight() + getItemHeight();
      } else {
        offset = offset + getItemHeight();
      }
    });
}

通过letter获取滚动的指定高度,如下面代码所示

double getOffset(String letter) => _letterOffsetMap[letter];

当获取到高度后,完成ListView的滚动,如下面代码所示

if (offset != null) {
    _scrollController.jumpTo(offset.clamp(
            .0, _scrollController.position.maxScrollExtent));
}

使用CustomPainter封装SideBar

上文SideBar的封装,用SizeBox封装每个letter,然后存放在List<Widget>列表中,最后填充Column,如下面代码所示

List<Widget> children = List();
A_Z_LIST.forEach((v) {
    children.add(SizedBox(
        width: widget.letterWidth.toDouble(),
        height: widget.letterHeight.toDouble(),
        child: Text(v, textAlign: TextAlign.center, style: _style),
    ));
});

child: Column(
    mainAxisSize: MainAxisSize.min,
    children: children,
),

下面用CustomPainter实现SideBar,如下面代码所示

class _SideBarPainter extends CustomPainter {
  final TextStyle textStyle;
  final int width;
  final int height;

  TextPainter _textPainter;

  _SideBarPainter(this.textStyle, this.width, this.height) {
    _textPainter = new TextPainter(
      textAlign: TextAlign.center,
      textDirection: TextDirection.ltr,
    );
  }

  @override
  void paint(Canvas canvas, Size size) {
    int length = A_Z_LIST.length;

    for (int i = 0; i < length; i++) {
      _textPainter.text = new TextSpan(
        text: A_Z_LIST[i],
        style: textStyle,
      );

      _textPainter.layout();
      _textPainter.paint(
          canvas, Offset(width.toDouble() / 2, i * height.toDouble()));
    }
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}

使用_SideBarPainter如下面代码所示

child: CustomPaint(
    painter: _SideBarPainter(
        widget.textStyle, widget.width, widget.letterHeight),
    size: Size(widget.width.toDouble(), _height),
),

项目源码

OpenGit_Fultter

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

推荐阅读更多精彩内容