fujidaiti / smooth_sheets

Sheet widgets with smooth motion and great flexibility.
https://pub.dev/packages/smooth_sheets
MIT License
195 stars 19 forks source link

DropdownButton doesn't work in NavigationSheet #139

Closed Prymethron closed 3 months ago

Prymethron commented 3 months ago

I was trying to use some dropdown in navigation sheet but I got errors when I tried to open dropdown menu. These are the errors according to versions:

For version 0.4.2: The following assertion was thrown during performLayout(): The sheet extent and the dimensions values must be finalized during the layout phase. 'package:smooth_sheets/src/foundation/framework.dart': Failed assertion: line 125 pos 7: '_extent.hasPixels'

For version 0.5.3: The following assertion was thrown while notifying listeners for ProxyAnimation: 'package:smooth_sheets/src/navigation/navigation_sheet.dart': Failed assertion: line 138 pos 12: '_localExtentScopeKeyRegistry.containsKey(route)': is not true.

Code:

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

void main() {
  runApp(const MaterialApp(
    home: HomePage(),
  ));
}

class HomePage extends StatelessWidget {
  const HomePage({
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Smooth Sheets Example'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            BaseModal.show(context);
          },
          child: const Text('Show Modal'),
        ),
      ),
    );
  }
}

class BaseModal extends StatelessWidget {
  const BaseModal({super.key});

  static Future<dynamic> show(BuildContext context) async {
    return await Navigator.push(
        context,
        ModalSheetRoute(
          builder: (context) => const BaseModal(),
        ));
  }

  @override
  Widget build(BuildContext context) {
    final transitionObserver = NavigationSheetTransitionObserver();

    final nestedNavigator = Navigator(
      observers: [transitionObserver],
      onGenerateInitialRoutes: (navigator, initialRoute) {
        return [
          ScrollableNavigationSheetRoute(
            builder: (context) {
              return const BasePage();
            },
          ),
        ];
      },
    );

    return SafeArea(
      bottom: false,
      child: SheetDismissible(
        child: NavigationSheet(
          transitionObserver: transitionObserver,
          child: Material(
            color: Colors.white,
            clipBehavior: Clip.antiAlias,
            borderRadius: const BorderRadius.only(topLeft: Radius.circular(20), topRight: Radius.circular(20)),
            child: nestedNavigator,
          ),
        ),
      ),
    );
  }
}

class BasePage extends StatelessWidget {
  const BasePage({
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return SafeArea(
        top: false,
        child: SingleChildScrollView(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            mainAxisSize: MainAxisSize.min,
            children: [
              Container(
                padding: const EdgeInsets.all(20),
                height: 300,
                color: Colors.amber,
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    const Text('Dropdown:'),
                    const SizedBox(height: 10),
                    BaseDropdownButton(dropdownValue: "ABC", items: [
                      DropdownMenuItemModel(text: "ABC", value: "ABC"),
                      DropdownMenuItemModel(text: "DEF", value: "DEF"),
                      DropdownMenuItemModel(text: "GHI", value: "GHI"),
                    ]),
                  ],
                ),
              ),
            ],
          ),
        ));
  }
}
Prymethron commented 3 months ago

Actually I examined NavigationSheetTransitionObserver and seems like dropdown route also extended from ModalRoute so it tries to apply ForwardTransition which is does not fit. So it might be fixed by changing existing conditional statements or adding new ones.

fujidaiti commented 3 months ago

Hi @Prymethron,

seems like dropdown route also extended from ModalRoute so it tries to apply ForwardTransition which is does not fit.

Furthermore, the current NavigationSheet only supports NavigationSheetRoute and its subclasses (and of course not the dropdown route).

bqubique commented 3 months ago

For anyone experiencing these issues, I'm currently using the following as a temporary solution (it uses Overlay widget to display the options):

import 'package:flutter/material.dart';

class CustomDropdown<T> extends StatefulWidget {
  /// the child widget for the button, this will be ignored if text is supplied
  final Widget child;

  /// onChange is called when the selected option is changed.;
  /// It will pass back the value and the index of the option.
  final void Function(int) onChange;

  /// list of DropdownItems
  final List<DropdownItem<T>> items;
  final DropdownStyle dropdownStyle;

  /// dropdownButtonStyles passes styles to OutlineButton.styleFrom()
  final DropdownButtonStyle dropdownButtonStyle;

  /// dropdown button icon defaults to caret
  final Icon? icon;
  final bool hideIcon;

  /// if true the dropdown icon will as a leading icon, default to false
  final bool leadingIcon;

  const CustomDropdown({
    super.key,
    this.hideIcon = false,
    required this.child,
    required this.items,
    this.dropdownStyle = const DropdownStyle(),
    this.dropdownButtonStyle = const DropdownButtonStyle(),
    this.icon,
    this.leadingIcon = false,
    required this.onChange,
  });

  @override
  State<CustomDropdown> createState() => _CustomDropdownState();
}

class _CustomDropdownState<T> extends State<CustomDropdown<T>>
    with TickerProviderStateMixin {
  final LayerLink _layerLink = LayerLink();
  final ScrollController _scrollController =
      ScrollController(initialScrollOffset: 0);
  late OverlayEntry _overlayEntry;
  bool _isOpen = false;
  int _currentIndex = -1;
  late AnimationController _animationController;
  late Animation<double> _expandAnimation;
  late Animation<double> _rotateAnimation;

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

    _animationController = AnimationController(
        vsync: this, duration: const Duration(milliseconds: 200));
    _expandAnimation = CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeInOut,
    );
    _rotateAnimation = Tween(begin: 0.0, end: 0.5).animate(CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeInOut,
    ));
  }

  @override
  Widget build(BuildContext context) {
    var style = widget.dropdownButtonStyle;
    // link the overlay to the button
    return CompositedTransformTarget(
      link: _layerLink,
      child: Container(
        width: style.width,
        height: style.height,
        padding: style.padding,
        decoration: BoxDecoration(
          color: style.backgroundColor,
        ),
        child: InkWell(
          onTap: _toggleDropdown,
          child: Row(
            mainAxisAlignment:
                style.mainAxisAlignment ?? MainAxisAlignment.center,
            textDirection:
                widget.leadingIcon ? TextDirection.rtl : TextDirection.ltr,
            mainAxisSize: MainAxisSize.min,
            children: [
              widget.child,
            ],
          ),
        ),
      ),
    );
  }

  OverlayEntry _createOverlayEntry() {
    // find the size and position of the current widget
    RenderBox renderBox = context.findRenderObject()! as RenderBox;
    var size = renderBox.size;

    var offset = renderBox.localToGlobal(Offset.zero);
    var topOffset = offset.dy + size.height + 5;
    return OverlayEntry(
      // full screen GestureDetector to register when a
      // user has clicked away from the dropdown
      builder: (context) => GestureDetector(
        onTap: () => _toggleDropdown(close: true),
        behavior: HitTestBehavior.translucent,
        // full screen container to register taps anywhere and close drop down
        child: SizedBox(
          height: MediaQuery.of(context).size.height,
          width: MediaQuery.of(context).size.width,
          child: Stack(
            children: [
              Positioned(
                left: offset.dx,
                top: topOffset,
                width: widget.dropdownStyle.width ?? size.width,
                child: CompositedTransformFollower(
                  offset:
                      widget.dropdownStyle.offset ?? Offset(0, size.height + 5),
                  link: _layerLink,
                  showWhenUnlinked: false,
                  child: Material(
                    elevation: widget.dropdownStyle.elevation ?? 0,
                    color: widget.dropdownStyle.color,
                    shape: widget.dropdownStyle.shape,
                    child: SizeTransition(
                      axisAlignment: 1,
                      sizeFactor: _expandAnimation,
                      child: ConstrainedBox(
                        constraints: widget.dropdownStyle.constraints ??
                            BoxConstraints(
                              maxHeight: (MediaQuery.of(context).size.height -
                                          topOffset -
                                          15)
                                      .isNegative
                                  ? 100
                                  : MediaQuery.of(context).size.height -
                                      topOffset -
                                      15,
                            ),
                        child: RawScrollbar(
                          thumbVisibility: true,
                          thumbColor: widget.dropdownStyle.scrollbarColor ??
                              Colors.grey,
                          controller: _scrollController,
                          child: ListView(
                            padding:
                                widget.dropdownStyle.padding ?? EdgeInsets.zero,
                            shrinkWrap: true,
                            controller: _scrollController,
                            children: widget.items.asMap().entries.map((item) {
                              return InkWell(
                                onTap: () {
                                  setState(() => _currentIndex = item.key);
                                  widget.onChange(item.key);
                                  _toggleDropdown();
                                },
                                child: item.value,
                              );
                            }).toList(),
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  void _toggleDropdown({bool close = false}) async {
    if (_isOpen || close) {
      await _animationController.reverse();
      _overlayEntry.remove();
      setState(() {
        _isOpen = false;
      });
    } else {
      _overlayEntry = _createOverlayEntry();
      Overlay.of(context).insert(_overlayEntry);
      setState(() => _isOpen = true);
      _animationController.forward();
    }
  }
}

/// DropdownItem is just a wrapper for each child in the dropdown list.\n
/// It holds the value of the item.
class DropdownItem<T> extends StatelessWidget {
  final T? value;
  final Widget child;

  const DropdownItem({super.key, this.value, required this.child});

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

class DropdownButtonStyle {
  final MainAxisAlignment? mainAxisAlignment;
  final ShapeBorder? shape;
  final double elevation;
  final Color? backgroundColor;
  final EdgeInsets? padding;
  final BoxConstraints? constraints;
  final double? width;
  final double? height;
  final Color? primaryColor;

  const DropdownButtonStyle({
    this.mainAxisAlignment,
    this.backgroundColor,
    this.primaryColor,
    this.constraints,
    this.height,
    this.width,
    this.elevation = 0,
    this.padding,
    this.shape,
  });
}

class DropdownStyle {
  final double? elevation;
  final Color? color;
  final EdgeInsets? padding;
  final BoxConstraints? constraints;
  final Color? scrollbarColor;

  /// Add shape and border radius of the dropdown from here
  final ShapeBorder? shape;

  /// position of the top left of the dropdown relative to the top left of the button
  final Offset? offset;

  ///button width must be set for this to take effect
  final double? width;

  const DropdownStyle({
    this.constraints,
    this.offset,
    this.width,
    this.elevation,
    this.shape,
    this.color,
    this.padding,
    this.scrollbarColor,
  });
}

Credits to the author(s) here.