Flutter: Custom Dropdown Widget

Using RenderBox and PageRouteBuilder

Soo Kim
4 min readFeb 14, 2023

I’m not a big fan of Flutter’s built in Dropdown Widget’s UI and the lack of UI control, so I decided to make one myself. The widget works fine, but I couldn’t figure out how to properly animate it (gradually opening and closing the widget). Despite the fact that I feel like the widget is incomplete due to the lack of animation, I’m sharing this post today to share how to use RenderBox.

I’ll be going over (1) how to find where your widget is on the screen using RenderBox, (2) the dropdown options, and (3) rendering the dropdown options with PageRouteBuilder.

Dropdown Widget UI

Here’s the UI.

class Main extends StatelessWidget {
const Main({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
final DropdownProvider _provider = Provider.of<DropdownProvider>(context);
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
DropdownWidget(
initText: "Select a Drink",
options: _provider.drinks,
onTap: (String text) {
print(text);
_provider.changeDrink(text);
}
),
],
),
),
);
}
}

How to Find Where Your Widget Is on the Screen

In order to do so, we need to understand that BuildContext is a
handle to the location of a widget in the widget tree.So, each context refers to a widget, which we. can use to find offset of a widget. With the context of the given widget, we can use findRenderObject( ) to determine it’s offset. Then, use localToGlobal( ) to find the offset of the top-left corner of the widget relative to the whole screen.

final RenderBox _box = context.findRenderObject() as RenderBox;
final Offset _offset = _box.localToGlobal(Offset.zero);

Note that findRenderObject( ) can only be called after the build process has been completed, and should be called from interaction handlers (like gesture detector) or paint call.

However, Rect is a much more convenient class to use since you can get offset of all corners of the widget and its size. Rect can be created with offset and size using & operator. You would use this rect to decide where to render the dropdown options.

final RenderBox _box = context.findRenderObject() as RenderBox;
final Rect _rect = _box.localToGlobal(Offset.zero) & _box.size;
print(_rect.topLeft); // Offset(62.5, 386.0)
print(_rect.topRight); // Offset(312.5, 386.0)
print(_rect.bottom); // 426.0. (top y-axis + size.width) (386 + 40)
print(_rect.size); // Size(250.0, 40.0)

Dropdown Options

Initially, use _rect to find the bottom and left values, and stack the dropdown option list below the dropdown widget. If an option is already selected, find it’s rect using the index and height of the individual options. The selected option will be shown with blue highlight.

double _top = _rect.bottom;
if (this._selectedText != null) {
final int _index = this.widget.options.indexOf(this._selectedText!);
_top -= (_index + 1) * this.widget.height;
}

Widget _dropdowns = Stack(
alignment: Alignment.topLeft,
children: [
Positioned(
top: _top,
height: _rect.height * this.widget.options.length + 1.0,
left: _rect.left,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(this.widget.borderRadius),
border: Border.all(strokeAlign: StrokeAlign.inside),
boxShadow: [
BoxShadow(color: Colors.grey.withOpacity(0.5), blurRadius: 2.0, spreadRadius: 1.0, offset: Offset(0.0, 2.0)),
]
),
width: this.widget.width,
child: Column(
mainAxisSize: MainAxisSize.min,
children: this.widget.options.map<Widget>((String text) {
final int _index = this.widget.options.indexOf(text);
double _height = _rect.height;
// the below portion was to account for the border widths
// the dropdown option text was slightly off from the dropdown widget text
if (_index == 0 || _index == this.widget.options.length) {
_height -= 1.0;
}
return Material(
// this color portion is for the dropdown option border corners
// without it, the corners are not shown properly
color: this._selectedText == text && _index != this.widget.options.length -1 && _index != 0
? Colors.white
: Colors.transparent,
child: InkWell(
onTap: () {
Navigator.of(ctx).pop(text);
},
child: this._dropDownWidget(text, _height,),
),
);
}).toList(),
),
),
),
]);

How to Show the Dropdown Options

So this is the tricky part. If you use just stack within the widget, the options either won’t be shown in the right place, or not shown at all (depending on whether you use Positioned or not).

We have all used things like showDialog or showBottomSheet before. What these do is basically push a Material widget (or Cupertino widget in the case of Cupertino actions) above the current screen.

showDialog

Instead, we use PageRouteBuilder. Navigator.of(context).push takes the abstract class Route<T> as its argument, and PageRouteBuilder extends Route. It allows us to tweak the opacity (if true, the page from which the new widget was pushed will not be shown) and barrier (barrierDismissible, barrierColor).

The code below shows the dropdown options.

onTap: () async {
final RenderBox _box = context.findRenderObject() as RenderBox;
final Rect _rect = _box.localToGlobal(Offset.zero) & _box.size;

// push the drop down options and return selected
final String _text = await Navigator.of(context).push<String>(
PageRouteBuilder(
transitionsBuilder: (_, Animation<double> a1, Animation<double> a2, widget) {
final CurvedAnimation _curvedAnimation = CurvedAnimation(parent: a1, curve: Curves.fastLinearToSlowEaseIn);
return FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(_curvedAnimation),
child: widget,
);
},
transitionDuration: const Duration(seconds: 1),
reverseTransitionDuration: const Duration(milliseconds: 300),
opaque: false,
barrierDismissible: true,
pageBuilder: (BuildContext ctx, Animation<double> a1, Animation<double> a2) {
// the dropdown options code above
return _dropdowns;
}),
) ?? this._selectedText ?? this.widget.initText;
this.setState(() {
this._selectedText = _text;
});
},

Happy Coding! Here’s the full code.

--

--