Como saber se a rolagem não é necessária em um SingleChildScrollView quando a tela é construída pela primeira vez?

Nov 25 2020

Eu tenho um SingleChildScrollView. Às vezes, childé mais longo do que a tela, caso em que SingleChildScrollViewpermite rolar. Mas também às vezes childé menor do que a tela, caso em que nenhuma rolagem é necessária.

Estou tentando adicionar uma seta na parte inferior da tela que indica ao usuário que ele pode / deve rolar para baixo para ver o resto do conteúdo. Eu implementei isso com sucesso, exceto no caso em que o childdo SingleChildScrollViewé mais curto do que a tela. Neste caso, nenhuma rolagem é necessária, então eu gostaria de não mostrar a seta.

Tentei fazer um listenerpara fazer isso, mas o listenernão é ativado até que você comece a rolar, e neste caso você não pode rolar.

Também tentei acessar as propriedades de _scrollControllerno operador ternário que mostra a seta, mas uma exceção é lançada:ScrollController not attached to any scroll views.

Aqui está um aplicativo de amostra completo que mostra o que estou fazendo, então você pode simplesmente copiar e colar se quiser que ele seja executado. Substituí todo o conteúdo por um Columnde Textwidgets para simplificar:

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) =>
      MaterialApp(home: Scaffold(body: MyScreen()));
}

class MyScreen extends StatefulWidget {
  @override
  _MyScreenState createState() => _MyScreenState();
}

class _MyScreenState extends State<MyScreen> {
  ScrollController _scrollController = ScrollController();
  bool atBottom = false;

  @override
  void initState() {
    super.initState();

    // Activated when you get to the bottom:
    _scrollController.addListener(() {
      if (_scrollController.position.extentAfter == 0) {
        setState(() {
          atBottom = true;
        });
      }
    });

    // Activated as soon as you start scrolling back up after getting to the bottom:
    _scrollController.addListener(() {
      if (_scrollController.position.extentAfter > 0 && atBottom) {
        setState(() {
          atBottom = false;
        });
      }
    });

    // I want this to activate if you are at the top of the screen and there is
    // no scrolling to do, i.e. the widget being displayed fits in the screen:
    _scrollController.addListener(() {
      if (_scrollController.offset == 0 &&
          _scrollController.position.extentAfter == 0) {
        setState(() {
          atBottom = false;
        });
      }
    });
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        SingleChildScrollView(
          controller: _scrollController,
          scrollDirection: Axis.vertical,
          child: Container(
            width: MediaQuery.of(context).size.width,
            child: Column(
              children: [
                for (int i = 0; i < 100; i++)
                  Text(
                    i.toString(),
                  ),
              ],
            ),
          ),
        ),
        atBottom
            ? Container()
            : Positioned(
                bottom: 10,
                right: 10,
                child: Container(
                  child: Icon(
                    Icons.arrow_circle_down,
                  ),
                ),
              ),
      ],
    );
  }
}

Respostas

VinayHP Nov 25 2020 at 19:15

Você precisa verificar se o _scrollController está anexado a uma visualização de rolagem usando sua propriedade hasClients primeiro.

@override
  void initState() {
    super.initState();

    // Activated when you get to the bottom:
    _scrollController.addListener(() {
      if (_scrollController.position.extentAfter == 0) {
        setState(() {
          atBottom = true;
        });
      }
    });

    // Activated as soon as you start scrolling back up after getting to the bottom:
    _scrollController.addListener(() {
      if (_scrollController.position.extentAfter > 0 && atBottom) {
        setState(() {
          atBottom = false;
        });
      }
    });

    // I want this to activate if you are at the top of the screen and there is
    // no scrolling to do, i.e. the widget being displayed fits in the screen:
    _scrollController.addListener(() {
      if (_scrollController.offset == 0 &&
          _scrollController.position.extentAfter == 0) {
        setState(() {
          atBottom = false;
        });
      }
    });
  }

mudar para:

@override
void initState() {
  super.initState();

  // Activated when you get to the bottom:
  _scrollController.addListener(() {
    if (_scrollController.hasClients){ // Like this
      if (_scrollController.position.extentAfter == 0) {
      setState(() {
        atBottom = true;
      });
    }}
  });

  // Activated as soon as you start scrolling back up after getting to the bottom:
  _scrollController.addListener(() {
    if (_scrollController.hasClients){ // Like this
    if (_scrollController.position.extentAfter > 0 && atBottom) {
      setState(() {
        atBottom = false;
      });
    }}
  });

  // I want this to activate if you are at the top of the screen and there is
  // no scrolling to do, i.e. the widget being displayed fits in the screen:
  _scrollController.addListener(() {
    if (_scrollController.hasClients){ // Like this
    if (_scrollController.offset == 0 &&
        _scrollController.position.extentAfter == 0) {
      setState(() {
        atBottom = false;
      });
    }}
  });
}
MichaelRodeman Nov 25 2020 at 22:49
  1. Importe a biblioteca Flutter do planejador:
import 'package:flutter/scheduler.dart';
  1. Crie um sinalizador booleano dentro do objeto de estado, mas fora do buildmétodo para rastrear se buildjá foi chamado:
bool buildCalledYet = false;
  1. Adicione o seguinte no início do buildmétodo:
if (!firstBuild) {
      firstBuild = true;
      SchedulerBinding.instance.addPostFrameCallback((_) {
        setState(() {
          atBottom = !(_scrollController.position.maxScrollExtent > 0);
        });
      });
    }

(O sinalizador booleano evita que esse código buildseja chamado repetidamente.)

Aqui está o código completo do aplicativo de amostra que implementa esta solução:

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) =>
      MaterialApp(home: Scaffold(body: MyScreen()));
}

class MyScreen extends StatefulWidget {
  @override
  _MyScreenState createState() => _MyScreenState();
}

class _MyScreenState extends State<MyScreen> {
  ScrollController _scrollController = ScrollController();
  bool atBottom = false;
  // ======= new code =======
  bool buildCalledYet = false;
  // ========================

  @override
  void initState() {
    super.initState();

    _scrollController.addListener(() {
      if (_scrollController.position.extentAfter == 0) {
        setState(() {
          atBottom = true;
        });
      }
    });

    _scrollController.addListener(() {
      if (_scrollController.position.extentAfter > 0 && atBottom) {
        setState(() {
          atBottom = false;
        });
      }
    });

    // ======= The third listener is not needed. =======
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // =========================== new code ===========================
    if (!buildCalledYet) {
      buildCalledYet = true;
      SchedulerBinding.instance.addPostFrameCallback((_) {
        setState(() {
          atBottom = !(_scrollController.position.maxScrollExtent > 0);
        });
      });
    }
    // ================================================================

    return Stack(
      children: [
        SingleChildScrollView(
          controller: _scrollController,
          scrollDirection: Axis.vertical,
          child: Container(
            width: MediaQuery.of(context).size.width,
            child: Column(
              children: [
                for (int i = 0; i < 100; i++)
                  Text(
                    i.toString(),
                  ),
              ],
            ),
          ),
        ),
        atBottom
            ? Container()
            : Positioned(
                bottom: 10,
                right: 10,
                child: Container(
                  child: Icon(
                    Icons.arrow_circle_down,
                  ),
                ),
              ),
      ],
    );
  }
}

Encontrei essa solução em outra questão do Stack Overflow: Determinar a altura do widget de rolagem