You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter:实现自定义Bottom Sheet高度随内容自适应

可拖拽Bottom Sheet自适应内容高度解决方案

问题描述

我正在实现一个可拖拽的Bottom Sheet,支持手动展开/折叠,也能按住滑动临时折叠。目前功能基本正常,但Bottom Sheet高度无法自适应内容——现在弹窗底部刚好停在“Line 6”下方,因为我硬编码了maxExtent值。

原始代码

library draggable_bottom_sheet;

import 'package:flutter/material.dart';

class DraggableTest extends StatefulWidget {
  const DraggableTest({super.key});

  @override
  State<DraggableTest> createState() => _DraggableTestState();
}

class _DraggableTestState extends State<DraggableTest> {
  bool _collapsed = false;

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Bottom Sheet')),
      body: SafeArea(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            OutlinedButton(
                onPressed: () {
                  setState(() {
                    _collapsed = !_collapsed;
                  });
                },
                child: Text(_collapsed ? 'Expand' : 'Collapse')),
            DraggableBottomSheet(
              expandedWidget: _expandedWidget(),
              backgroundColor: Colors.yellow,
              collapsed: _collapsed,
              maxExtent: 250,
            ),
          ],
        ),
      ),
    );
  }

  Widget _expandedWidget() {
    return ListView(
      physics: const NeverScrollableScrollPhysics(),
      children: const [
        Text('Title', style: TextStyle(fontSize: 22)),
        Text('Line 1', style: TextStyle(fontSize: 18)),
        Text('Line 2', style: TextStyle(fontSize: 18)),
        Text('Line 3', style: TextStyle(fontSize: 18)),
        Text('Line 4', style: TextStyle(fontSize: 18)),
        Text('Line 5', style: TextStyle(fontSize: 18)),
        Text('Line 6', style: TextStyle(fontSize: 18)),
      ],
    );
  }
}

class DraggableBottomSheet extends StatefulWidget {
  final Widget expandedWidget;
  final Color backgroundColor;
  final bool collapsed;
  final double maxExtent;

  const DraggableBottomSheet({
    required widget.expandedWidget,
    required widget.backgroundColor,
    required widget.collapsed,
    required widget.maxExtent,
    super.key,
  });

  @override
  State<DraggableBottomSheet> createState() => _DraggableBottomSheetState();
}

class _DraggableBottomSheetState extends State<DraggableBottomSheet> {
  double _currentExtent = 0.0;
  final _minExtent = 50.0;
  bool _isDragging = false;

  @override
  void initState() {
    _currentExtent =
        widget.collapsed ? _minExtent : widget.maxExtent; // <<<< 问题根源:硬编码高度
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      color: widget.backgroundColor,
      child: Align(alignment: Alignment.bottomCenter, child: _sheet()),
    );
  }

  Widget _sheet() {
    return GestureDetector(
      onVerticalDragUpdate: _onVerticalDragUpdate,
      onVerticalDragStart: _onVerticalDragStart,
      onVerticalDragEnd: _onVerticalDragEnd,
      child: AnimatedContainer(
        curve: Curves.bounceOut,
        duration: _isDragging ? Duration.zero : const Duration(milliseconds: 500),
        height: widget.collapsed ? _minExtent : _currentExtent,
        child: widget.collapsed ? null : widget.expandedWidget,
      ),
    );
  }

  void _onVerticalDragEnd(DragEndDetails details) {
    setState(() {
      _isDragging = false;
      _currentExtent = widget.maxExtent;
    });
  }

  void _onVerticalDragStart(DragStartDetails details) {
    setState(() {
      _isDragging = true;
    });
  }

  void _onVerticalDragUpdate(DragUpdateDetails details) {
    if (widget.collapsed) return;

    final newExtent = (_currentExtent - details.delta.dy).roundToDouble();
    if (newExtent >= _minExtent && newExtent <= widget.maxExtent) {
      setState(() => _currentExtent = newExtent);
    }
  }
}

修改后的代码

library draggable_bottom_sheet;

import 'package:flutter/material.dart';

class DraggableTest extends StatefulWidget {
  const DraggableTest({super.key});

  @override
  State<DraggableTest> createState() => _DraggableTestState();
}

class _DraggableTestState extends State<DraggableTest> {
  bool _collapsed = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Bottom Sheet')),
      body: SafeArea(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            OutlinedButton(
              onPressed: () => setState(() => _collapsed = !_collapsed),
              child: Text(_collapsed ? '展开' : '收起'),
            ),
            DraggableBottomSheet(
              expandedWidget: _expandedWidget(),
              backgroundColor: Colors.yellow,
              collapsed: _collapsed,
            ),
          ],
        ),
      ),
    );
  }

  Widget _expandedWidget() {
    // 替换ListView为Column,避免不必要的滚动容器,便于计算高度
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: const [
        Text('Title', style: TextStyle(fontSize: 22)),
        Text('Line 1', style: TextStyle(fontSize: 18)),
        Text('Line 2', style: TextStyle(fontSize: 18)),
        Text('Line 3', style: TextStyle(fontSize: 18)),
        Text('Line 4', style: TextStyle(fontSize: 18)),
        Text('Line 5', style: TextStyle(fontSize: 18)),
        Text('Line 6', style: TextStyle(fontSize: 18)),
      ],
    );
  }
}

class DraggableBottomSheet extends StatefulWidget {
  final Widget expandedWidget;
  final Color backgroundColor;
  final bool collapsed;

  const DraggableBottomSheet({
    required this.expandedWidget,
    required widget.backgroundColor,
    required widget.collapsed,
    super.key,
  });

  @override
  State<DraggableBottomSheet> createState() => _DraggableBottomSheetState();
}

class _DraggableBottomSheetState extends State<DraggableBottomSheet> {
  double _currentExtent = 0.0;
  final _minExtent = 50.0;
  bool _isDragging = false;
  late double _contentHeight;
  final GlobalKey _contentKey = GlobalKey();

  @override
  void initState() {
    super.initState();
    // 组件渲染完成后计算内容实际高度
    WidgetsBinding.instance.addPostFrameCallback((_) => _calculateContentHeight());
  }

  @override
  void didUpdateWidget(covariant DraggableBottomSheet oldWidget) {
    super.didUpdateWidget(oldWidget);
    // 内容或折叠状态变化时,重新计算高度
    if (oldWidget.expandedWidget != widget.expandedWidget || oldWidget.collapsed != widget.collapsed) {
      WidgetsBinding.instance.addPostFrameCallback((_) => _calculateContentHeight());
    }
  }

  void _calculateContentHeight() {
    final renderBox = _contentKey.currentContext?.findRenderObject() as RenderBox?;
    if (renderBox != null) {
      setState(() {
        _contentHeight = renderBox.size.height;
        // 根据折叠状态初始化高度
        _currentExtent = widget.collapsed ? _minExtent : _contentHeight;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      color: widget.backgroundColor,
      child: Align(alignment: Alignment.bottomCenter, child: _sheet()),
    );
  }

  Widget _sheet() {
    return GestureDetector(
      onVerticalDragUpdate: _onVerticalDragUpdate,
      onVerticalDragStart: _onVerticalDragStart,
      onVerticalDragEnd: _onVerticalDragEnd,
      child: AnimatedContainer(
        curve: Curves.bounceOut,
        duration: _isDragging ? Duration.zero : const Duration(milliseconds: 500),
        height: widget.collapsed ? _minExtent : _currentExtent,
        child: widget.collapsed
            ? null
            : KeyedSubtree(
                key: _contentKey,
                child: widget.expandedWidget,
              ),
      ),
    );
  }

  void _onVerticalDragEnd(DragEndDetails details) {
    setState(() {
      _isDragging = false;
      // 拖拽结束后恢复到内容原高度
      _currentExtent = _contentHeight;
    });
  }

  void _onVerticalDragStart(DragStartDetails details) {
    setState(() {
      _isDragging = true;
    });
  }

  void _onVerticalDragUpdate(DragUpdateDetails details) {
    if (widget.collapsed) return;

    final newExtent = (_currentExtent - details.delta.dy).roundToDouble();
    // 限制拖拽范围在最小高度和内容实际高度之间
    if (newExtent >= _minExtent && newExtent <= _contentHeight) {
      setState(() => _currentExtent = newExtent);
    }
  }
}

关键改动说明

  • 替换滚动容器:把不可滚动的ListView换成Column,设置mainAxisSize: MainAxisSize.min让内容自动适配高度,简化高度计算。
  • 动态计算内容高度:通过GlobalKey获取内容的RenderBox,在组件渲染完成后计算实际高度,替代硬编码的maxExtent。
  • 适配状态变化:在didUpdateWidget中监听内容或折叠状态的变化,重新计算高度,确保动态内容也能正确适配。
  • 优化拖拽逻辑:拖拽范围限制在最小高度和内容实际高度之间,避免出现内容截断或空白区域。

内容的提问来源于stack exchange,提问作者Denis

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.01 23:42:51