Flutter监听ListView滚动到哪个Widget上

1,097 阅读4分钟

前言

日常开发中,经常会碰到需要监听ListView滚动到什么位置,或者屏幕上显示了哪几个Widget的情况,而 ScrollController 只能监听滚动的距离,而项目中ListView的Widget的高度又不固定,上网搜搜,发现了 ScrollablePositionedList 可以实现,但是在 ScrollablePositionedList 外部嵌套 SmartRefresher 时会有无法下拉刷新的Bug,两大神级插件无法共用的即视感,没办法,只能自己动手了,摸鱼时间-1 -1 -1.

思路

既然我们可以监听ListView滚动的位置,那么只需要知道ListView中每个Item在ListView中的位置就可以了,而获取所在位置使用GlobalKey就可以拿到,但是众所周知,GlobalKey拿到的位置或者宽高都得先在屏幕上渲染之后才存在真实的值,那我们先写个方法,让widget渲染一帧之后回调给我们

import 'package:flutter/material.dart';

///渲染完第一帧之后回调
class AfterRenderingFirstFrame extends StatefulWidget {
  const AfterRenderingFirstFrame({
    Key? key,
    required this.child,
    required this.callback
  }) : super(key: key);
  final Widget child;
  final VoidCallback callback;

  @override
  State<AfterRenderingFirstFrame> createState() => _AfterRenderingFirstFrameState();
}

class _AfterRenderingFirstFrameState extends State<AfterRenderingFirstFrame> {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.endOfFrame.then((_) {
      widget.callback();
    });
  }

  @override
  Widget build(BuildContext context) {
    return widget.child;
  }

  @override
  void didUpdateWidget(AfterRenderingFirstFrame oldWidget) {
    super.didUpdateWidget(oldWidget);
    if(oldWidget.child != widget.child) {
      WidgetsBinding.instance.endOfFrame.then((_) {
        widget.callback();
      });
    }
  }
}

didUpdateWidget也会回调是因为正常情况下数据是异步拿到的,或者后更新ListView的情况,只要child发生改变就回调一次,用于更新该Item在ListView的位置

具体实现

先定义可能需要的变量

final _listKey = GlobalKey(); //<--可以知道listview在屏幕上的位置
final listViewController = ScrollController(); //<--用于监听listview滚动的距离
final List<Item> dataList = []; //数据列表
double currentPosition = 0; //记录当前滚动的位置,可以用于对比下次滚动是上滚还是下滚
ValueNotifier<int> currentIndex = ValueNotifier(0); //游标,记录当前滚动到哪个Item上

Item类是实际业务中的数据,但是需要在item中加个变量,用于记录当前Widget在listview的位置

class Item {
  String? value;
  double? position; //增加一个字段用于记录当前Item在ListView中的位置

  Item({this.value, this.position});
}

接下来我们需要获取Widget在listview的位置渲染之后并赋值回去即可,使用

RenderBox renderBox = GlobalKey().currentContext!.findRenderObject() as RenderBox;
renderBox.localToGlobal(Offset(x, y)).dy;

localToGlobal可以获得以某个坐标为基础,在此坐标上加上自己在屏幕的位置,如果坐标为Offset.zero的话就是自己在屏幕上的位置
但是ListView获得在屏幕上的位置是不够的,如图

微信截图_20230810134117.png

ListView是可以滚动的,如果获取其中Item在屏幕中的位置则是不准确的,而需要获取Widget在Listview位置则可以获取
Item在屏幕的位置 + ListView滚动的距离 - ListView所在位置
所得结果就是item在listview上的位置,对了,这里所有的位置和距离都是相对Y轴的

不多说了,上代码

先用extension封装一下GlobalKey

extension GlobalKeyExtension on GlobalKey {
  ///y轴基于某个点位置
  double localToDy(double localDy) {
    RenderBox renderBox = currentContext!.findRenderObject() as RenderBox;
    return renderBox.localToGlobal(Offset(0, localDy)).dy;
  }

  ///y轴相对屏幕顶部位置
  double get dy {
    RenderBox renderBox = currentContext!.findRenderObject() as RenderBox;
    return renderBox.localToGlobal(Offset.zero).dy;
  }

  ///获取widget的高度
  double get height {
    RenderBox renderBox = currentContext!.findRenderObject() as RenderBox;
    return renderBox.size.height;
  }
}

监听ListView滚动,使用listview滚动控制器即可,之前定义了currentPosition变量用来和当前listview滚动作比较,可以知道是上滑还是下滑列表
currentIndex是ValueNotifier类型,用于局部刷新,当然不需要则换成int类型也一样

//监听滚动的ListView显示的第一个的item
listViewController.addListener(() {
  if(listViewController.offset < currentPosition) {
    //当前滚动位置小于上次记录的位置
    //说明listview在往上滚动
    //按道理来说 [position] 不会为空,但是宁杀错勿错过(没自信用!)
    if((dataList[currentIndex.value].position??0) > listViewController.offset) {
      if(currentIndex.value > 0) {
        currentIndex.value = currentIndex.value - 1;
      }
    }
  }else {
    //正常来说这个是一定成立的,除非一个屏幕只显示了一个item
    if(dataList.length > currentIndex.value + 1) {
      if(listViewController.offset > (dataList[currentIndex.value + 1].position??0)) {
        currentIndex.value = currentIndex.value + 1;
      }
    }
  }
  currentPosition = listViewController.offset;
});

而界面就随便写写了,主要实现功能即可

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: ValueListenableBuilder(
        valueListenable: currentIndex,
        builder: (_, value, child) {
          return Text(
            "当前滚动到第$value个Item"
          );
        },
      ),
      centerTitle: true,
    ),
    backgroundColor: Colors.white,
    body: SizedBox(
      key: _listKey,
      child: ListView.builder(
        controller: listViewController,
        itemCount: dataList.length,
        itemBuilder: (_, index) {
          final _key = GlobalKey();
          return AfterRenderingFirstFrame(
            child: Container(
              key: _key,
              height: 200,
              color: getRandomColor,
              alignment: Alignment.center,
              child: Text(
                dataList[index].value??""
              ),
            ),
            callback: () {
              double listY = _listKey.dy; //获取listview在屏幕中的位置
              double listItemY = _key.localToDy(listViewController.offset); //获取item在屏幕位置加listview滚动距离
              dataList[index].position = listItemY - listY; //相减即可得到item在list所在位置
            },
          );
        },
      ),
    ),
  );
}

//获取随机颜色
Color get getRandomColor {
  Random random = Random();
  int r = random.nextInt(256);
  int g = random.nextInt(256);
  int b = random.nextInt(256);
  return Color.fromARGB(255, r, g, b);
}

最终效果

微信截图_20230810135755.png

总结

第一次发文章,写得乱七八糟的,不知道jym能不能看懂,之前写的笔记都是给自己看的,最近鱼被摸秃了想着发篇文章打发一下时间,也顺便记录一下。