├── .gitattributes
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── example
└── main.dart
├── flutter_reactive_button.iml
├── images.zip
├── lib
├── flutter_reactive_button.dart
└── src
│ ├── reactive_button.dart
│ ├── reactive_button_bloc.dart
│ ├── reactive_icon.dart
│ ├── reactive_icon_container.dart
│ ├── reactive_icon_definition.dart
│ ├── reactive_icon_selection_message.dart
│ └── widget_position.dart
├── pubspec.yaml
├── reactive_button.gif
└── test
└── flutter_reactive_button_test.dart
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # mac system
2 | .DS_Store
3 |
4 | # intellij
5 | .idea
6 |
7 | #dart / flutter
8 | .dart_tool/
9 | .packages
10 | .pub/
11 | pubspec.lock
12 | coverage
13 | __temp_coverage*
14 | build/
15 | ios/.generated/
16 | ios/Flutter/Generated.xcconfig
17 | ios/Runner/GeneratedPluginRegistrant.*
18 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## [0.1.0] - 2018-09-08
2 |
3 | - initial release
4 |
5 | ## [1.0.0] - 2018-09-08
6 |
7 | - code formatting
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | ReactiveButton
2 |
3 | A Widget that mimics the Facebook Reaction Button in Flutter
4 |
5 | Written by Didier Boelens (https://www.didierboelens.com).
6 |
7 | All rights reserved.
8 |
9 | Redistribution and use in source and binary forms, with or without
10 | modification, are permitted provided that the following conditions are met:
11 |
12 | * Redistributions of source code must retain the above copyright notice, this
13 | list of conditions and the following disclaimer.
14 | * Redistributions in binary form must reproduce the above copyright notice,
15 | this list of conditions and the following disclaimer in the documentation
16 | and/or other materials provided with the distribution.
17 | * Neither the name of the Posse Productions LLC, Posse nor the
18 | names of its contributors may be used to endorse or promote products
19 | derived from this software without specific prior written permission.
20 |
21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
22 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
24 | DISCLAIMED. IN NO EVENT SHALL POSSE PRODUCTIONS LLC (POSSE) BE LIABLE FOR ANY
25 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
26 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
28 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ReactiveButton
2 |
3 | A Widget that mimics the Facebook Reaction Button in Flutter.
4 |
5 |
6 |
7 |
8 | ---
9 | ## Step by step explanation
10 |
11 | A full explanation on how to build such Widget may be found on my blog:
12 |
13 | * in English, click [here](https://www.didierboelens.com/2018/09/reactive-button/)
14 | * in French, click [here](https://www.didierboelens.com/fr/2018/09/reactive-button/)
15 |
16 | ---
17 | ## Getting Started
18 |
19 | You should ensure that you add the following dependency in your Flutter project.
20 | ```yaml
21 | dependencies:
22 | flutter_reactive_button: "^1.0.0"
23 | ```
24 |
25 | You should then run `flutter packages upgrade` or update your packages in IntelliJ.
26 |
27 | In your Dart code, to use it:
28 | ```dart
29 | import 'package:flutter_reactive_button/flutter_reactive_button.dart';
30 | ```
31 |
32 | ---
33 | ## Icons
34 |
35 | Icons should be defined as assets and passed to the ReactiveButton Widget, via the **icons** property, which accepts a **List < ReactiveIconDefinition >**.
36 |
37 | For your convenience, you will find the images that are used in the sample, in the file '*images.zip*', please read the '*README.md*', included in the ZIP file, for further instructions on how to use these images in your project.
38 |
39 | ---
40 | ## Example
41 |
42 | An example can be found in the `example` folder. Check it out.
43 |
44 |
--------------------------------------------------------------------------------
/example/main.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:flutter_reactive_button/flutter_reactive_button.dart';
3 |
4 | void main() => runApp(new MyApp());
5 |
6 | class MyApp extends StatelessWidget {
7 | @override
8 | Widget build(BuildContext context) {
9 | return new MaterialApp(
10 | title: 'Reactive Button Demo',
11 | debugShowCheckedModeBanner: false,
12 | theme: new ThemeData(
13 | primarySwatch: Colors.blue,
14 | ),
15 | home: new PageReactiveButton(),
16 | );
17 | }
18 | }
19 |
20 | // ----------------------------------------------------------------
21 |
22 | class PageReactiveButton extends StatefulWidget {
23 | @override
24 | _PageReactiveButtonState createState() => _PageReactiveButtonState();
25 | }
26 |
27 | class _PageReactiveButtonState extends State {
28 | List _flags = [
29 | ReactiveIconDefinition(
30 | assetIcon: 'images/flag-de.png',
31 | code: 'de',
32 | ),
33 | ReactiveIconDefinition(
34 | assetIcon: 'images/flag-en.png',
35 | code: 'en',
36 | ),
37 | ReactiveIconDefinition(
38 | assetIcon: 'images/flag-fr.png',
39 | code: 'fr',
40 | ),
41 | ReactiveIconDefinition(
42 | assetIcon: 'images/flag-it.png',
43 | code: 'it',
44 | ),
45 | ReactiveIconDefinition(
46 | assetIcon: 'images/flag-nl.png',
47 | code: 'nl',
48 | ),
49 | ReactiveIconDefinition(
50 | assetIcon: 'images/flag-es.png',
51 | code: 'es',
52 | ),
53 | ReactiveIconDefinition(
54 | assetIcon: 'images/flag-pt.png',
55 | code: 'pt',
56 | ),
57 | ];
58 |
59 | List _facebook = [
60 | ReactiveIconDefinition(
61 | assetIcon: 'images/like.gif',
62 | code: 'like',
63 | ),
64 | ReactiveIconDefinition(
65 | assetIcon: 'images/haha.gif',
66 | code: 'haha',
67 | ),
68 | ReactiveIconDefinition(
69 | assetIcon: 'images/love.gif',
70 | code: 'love',
71 | ),
72 | ReactiveIconDefinition(
73 | assetIcon: 'images/sad.gif',
74 | code: 'sad',
75 | ),
76 | ReactiveIconDefinition(
77 | assetIcon: 'images/wow.gif',
78 | code: 'wow',
79 | ),
80 | ReactiveIconDefinition(
81 | assetIcon: 'images/angry.gif',
82 | code: 'angry',
83 | ),
84 | ];
85 |
86 | String countryCode = 'en';
87 | String facebook;
88 |
89 | @override
90 | Widget build(BuildContext context) {
91 | return Scaffold(
92 | appBar: AppBar(
93 | title: Text('ReactiveButton Sample'),
94 | actions: [
95 | ReactiveButton(
96 | icons: _flags,
97 | onSelected: (ReactiveIconDefinition button) {
98 | setState(() {
99 | countryCode = button.code;
100 | });
101 | },
102 | child: Image.asset(
103 | 'images/flag-$countryCode.png',
104 | width: 32.0,
105 | height: 32.0,
106 | ),
107 | containerAbove: false,
108 | iconWidth: 32.0,
109 | iconGrowRatio: 1.5,
110 | roundIcons: false,
111 | onTap: () {
112 | print('Hey I am hit');
113 | },
114 | decoration: BoxDecoration(
115 | border: Border.all(
116 | width: 1.0,
117 | color: Colors.black54,
118 | ),
119 | borderRadius: BorderRadius.circular(10.0),
120 | color: Color(0x55FFFFFF),
121 | ),
122 | ),
123 | SizedBox(
124 | width: 24.0,
125 | )
126 | ],
127 | ),
128 | body: SingleChildScrollView(
129 | scrollDirection: Axis.vertical,
130 | child: Column(
131 | children: [
132 | Container(
133 | height: 850.0,
134 | color: Colors.blueGrey,
135 | ),
136 | Padding(
137 | padding: const EdgeInsets.all(8.0),
138 | child: Text('Here is some text'),
139 | ),
140 | ReactiveButton(
141 | child: Container(
142 | decoration: BoxDecoration(
143 | border: Border.all(
144 | color: Colors.black,
145 | width: 1.0,
146 | ),
147 | color: Colors.white,
148 | ),
149 | width: 80.0,
150 | height: 40.0,
151 | child: Center(
152 | child: facebook == null
153 | ? Text('click')
154 | : Image.asset(
155 | 'images/$facebook.png',
156 | width: 32.0,
157 | height: 32.0,
158 | ),
159 | ),
160 | ),
161 | icons: _facebook, //_flags,
162 | onTap: () {
163 | print('TAP');
164 | },
165 | onSelected: (ReactiveIconDefinition button) {
166 | setState(() {
167 | facebook = button.code;
168 | });
169 | },
170 | iconWidth: 32.0,
171 | ),
172 | Container(
173 | height: 800.0,
174 | color: Colors.blueGrey,
175 | ),
176 | ],
177 | ),
178 | ),
179 | );
180 | }
181 | }
182 |
--------------------------------------------------------------------------------
/flutter_reactive_button.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/images.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boeledi/flutter_reactive_button/b937c00bad4a4aec705324ff56c7247ce57039f3/images.zip
--------------------------------------------------------------------------------
/lib/flutter_reactive_button.dart:
--------------------------------------------------------------------------------
1 | /*
2 | * ReactiveButton
3 | *
4 | * Copyright 2018 Didier Boelens. All rights reserved.
5 | * Use of this source code is governed by a BSD-style license that can be
6 | * found in the LICENSE file.
7 | */
8 | library flutter_reactive_button;
9 |
10 | export 'src/reactive_button.dart';
11 | export 'src/reactive_icon_definition.dart';
12 |
--------------------------------------------------------------------------------
/lib/src/reactive_button.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 | import 'package:flutter/material.dart';
3 | import 'package:flutter_reactive_button/src/reactive_icon_container.dart';
4 | import 'package:flutter_reactive_button/src/reactive_icon_definition.dart';
5 | import 'package:flutter_reactive_button/src/reactive_button_bloc.dart';
6 | import 'package:flutter_reactive_button/src/reactive_icon_selection_message.dart';
7 | import 'package:flutter_reactive_button/src/widget_position.dart';
8 |
9 | typedef ReactiveButtonCallback(ReactiveIconDefinition button);
10 |
11 | const double _kIconGrowRatio = 1.2;
12 | const double _kIconsPadding = 8.0;
13 | const Color _kKeyUmbraOpacity = Color(0x33000000); // alpha = 0.2
14 | const Color _kKeyPenumbraOpacity = Color(0x24000000); // alpha = 0.14
15 | const Color _kAmbientShadowOpacity = Color(0x1F000000); // alpha = 0.12
16 |
17 | final BoxDecoration _kDefaultDecoration = BoxDecoration(
18 | border: Border.all(
19 | width: 1.0,
20 | color: Colors.black54,
21 | ),
22 | borderRadius: BorderRadius.circular(10.0),
23 | boxShadow: [
24 | BoxShadow(
25 | offset: Offset(0.0, 3.0),
26 | blurRadius: 1.0,
27 | spreadRadius: -2.0,
28 | color: _kKeyUmbraOpacity),
29 | BoxShadow(
30 | offset: Offset(0.0, 2.0),
31 | blurRadius: 2.0,
32 | spreadRadius: 0.0,
33 | color: _kKeyPenumbraOpacity),
34 | BoxShadow(
35 | offset: Offset(0.0, 1.0),
36 | blurRadius: 5.0,
37 | spreadRadius: 0.0,
38 | color: _kAmbientShadowOpacity),
39 | ],
40 | color: Colors.white12,
41 | );
42 |
43 | /// A Widget that mimics the Facebook Reaction Button in Flutter.
44 | ///
45 | /// A ReactiveButton expects a minimum of 4 parameters [icons], [onSelected], [onTap], [child].
46 | ///
47 | /// The [icons] contains the list of all the icon assets which will be displayed, together with a code associate to them.
48 | /// The [onSelected] is called when the user releases the pointer over an icon.
49 | /// The [onTap] is called when the user has simply tapped the [ReactiveButton]
50 | /// The [child] defines the button itself as a Widget
51 | ///
52 | class ReactiveButton extends StatefulWidget {
53 | ReactiveButton({
54 | Key key,
55 | @required this.icons,
56 | @required this.onSelected,
57 | @required this.onTap,
58 | @required this.child,
59 | this.iconWidth: 32.0,
60 | this.roundIcons: true,
61 | this.iconPadding: _kIconsPadding,
62 | this.iconGrowRatio: _kIconGrowRatio,
63 | this.decoration,
64 | this.padding: const EdgeInsets.all(4.0),
65 | this.containerPadding: 4.0,
66 | this.containerAbove: true,
67 | }) : super(key: key);
68 |
69 | /// List of image assets, associated to a code, to be used (mandatory)
70 | final List icons;
71 |
72 | /// Callback to be used when the user makes a selection (mandatory)
73 | final ReactiveButtonCallback onSelected;
74 |
75 | /// Callback to be used when the user proceeds with a simple tap (mandatory)
76 | final VoidCallback onTap;
77 |
78 | /// Child (mandatory)
79 | final Widget child;
80 |
81 | /// Width of each individual icons (default: 32.0)
82 | final double iconWidth;
83 |
84 | /// Shape of the icons. Are they round? (default: true)
85 | final bool roundIcons;
86 |
87 | /// Padding between icons (default: 8.0)
88 | final double iconPadding;
89 |
90 | /// Icon grow ratio when hovered (default: 1.2)
91 | final double iconGrowRatio;
92 |
93 | /// Decoration of the container. If none provided, the default one will be used
94 | final Decoration decoration;
95 |
96 | /// Padding of the container (default: EdgeInsets.all(4.0))
97 | final EdgeInsets padding;
98 |
99 | /// Distance between the button and the container (default: 4.0)
100 | final double containerPadding;
101 |
102 | /// Do we prefer showing the container above the button (if there is room)? (default: true)
103 | final bool containerAbove;
104 |
105 | @override
106 | _ReactiveButtonState createState() => _ReactiveButtonState();
107 | }
108 |
109 | class _ReactiveButtonState extends State {
110 | ReactiveButtonBloc bloc;
111 | OverlayState _overlayState;
112 | OverlayEntry _overlayEntry;
113 | StreamSubscription streamSubscription;
114 | ReactiveIconDefinition _selectedButton;
115 |
116 | // Timer to be used to determine whether a longPress completes
117 | Timer timer;
118 |
119 | // Flag to know whether we dispatch the onTap
120 | bool isTap = true;
121 |
122 | // Flag to know whether the drag has started
123 | bool dragStarted = false;
124 |
125 | @override
126 | void initState() {
127 | super.initState();
128 |
129 | // Initialization of the OverlayButtonBloc
130 | bloc = ReactiveButtonBloc();
131 |
132 | // Start listening to messages from icons
133 | streamSubscription = bloc.outIconSelection.listen(_onIconSelectionChange);
134 | }
135 |
136 | @override
137 | void dispose() {
138 | _cancelTimer();
139 | _hideIcons();
140 | streamSubscription?.cancel();
141 | bloc?.dispose();
142 | super.dispose();
143 | }
144 |
145 | @override
146 | Widget build(BuildContext context) {
147 | return GestureDetector(
148 | onHorizontalDragStart: _onDragStart,
149 | onVerticalDragStart: _onDragStart,
150 | onHorizontalDragCancel: _onDragCancel,
151 | onVerticalDragCancel: _onDragCancel,
152 | onHorizontalDragEnd: _onDragEnd,
153 | onVerticalDragEnd: _onDragEnd,
154 | onHorizontalDragDown: _onDragReady,
155 | onVerticalDragDown: _onDragReady,
156 | onHorizontalDragUpdate: _onDragMove,
157 | onVerticalDragUpdate: _onDragMove,
158 | onTap: _onTap,
159 | child: widget.child,
160 | );
161 | }
162 |
163 | //
164 | // The user did a simple tap.
165 | // We need to tell the parent
166 | //
167 | void _onTap() {
168 | _cancelTimer();
169 | if (isTap && widget.onTap != null) {
170 | widget.onTap();
171 | }
172 | }
173 |
174 | // The user released his/her finger
175 | // We need to hide the icons and provide
176 | // his/her decision if any and if this is
177 | // not a Tap
178 | void _onDragEnd(DragEndDetails details) {
179 | _cancelTimer();
180 | _hideIcons();
181 | if (widget.onSelected != null && _selectedButton != null) {
182 | widget.onSelected(_selectedButton);
183 | }
184 | }
185 |
186 | void _onDragReady(DragDownDetails details) {
187 | // Let's wait some time to make the distinction
188 | // between a Tap and a LongTap
189 | isTap = true;
190 | dragStarted = false;
191 | _startTimer();
192 | }
193 |
194 | // Little trick to make sure we are hiding
195 | // the Icons container if a 'dragCancel' is
196 | // triggered while no move has been detected
197 | void _onDragStart(DragStartDetails details) {
198 | dragStarted = true;
199 | }
200 |
201 | void _onDragCancel() async {
202 | await Future.delayed(const Duration(milliseconds: 200));
203 | if (!dragStarted) {
204 | _hideIcons();
205 | }
206 | }
207 |
208 | //
209 | // The user is moving the pointer around the screen
210 | // We need to pass this information to whomever
211 | // might be interested (icons)
212 | //
213 | void _onDragMove(DragUpdateDetails details) {
214 | bloc.inPointerPosition.add(details.globalPosition);
215 | }
216 |
217 | // ###### LongPress related ##########
218 |
219 | void _startTimer() {
220 | _cancelTimer();
221 | timer = Timer(Duration(milliseconds: 500), _showIcons);
222 | }
223 |
224 | void _cancelTimer() {
225 | if (timer != null) {
226 | timer.cancel();
227 | timer = null;
228 | }
229 | }
230 |
231 | // ###### Icons related ##########
232 |
233 | // We have waited enough to consider that this is
234 | // a long Press. Therefore, let's display
235 | // the icons
236 | void _showIcons() {
237 | // It is no longer a Tap
238 | isTap = false;
239 |
240 | // Retrieve the Overlay
241 | _overlayState = Overlay.of(context);
242 |
243 | // Generate the ReactionIconContainer that will be displayed onto the Overlay
244 | _overlayEntry = OverlayEntry(
245 | builder: (BuildContext context) {
246 | return ReactiveIconContainer(
247 | icons: widget.icons,
248 | iconWidth: widget.iconWidth,
249 | position: _getIconsContainerPosition(),
250 | bloc: bloc,
251 | decoration: widget.decoration ?? _kDefaultDecoration,
252 | iconGrowRatio: widget.iconGrowRatio,
253 | iconPadding: widget.iconPadding,
254 | padding: widget.padding,
255 | roundIcons: widget.roundIcons,
256 | );
257 | },
258 | );
259 |
260 | // Add it to the Overlay
261 | _overlayState.insert(_overlayEntry);
262 | }
263 |
264 | void _hideIcons() {
265 | _overlayEntry?.remove();
266 | _overlayEntry = null;
267 | }
268 |
269 | // Routine that determines the position
270 | // of the icons container, related to the position
271 | // of the button
272 | Offset _getIconsContainerPosition() {
273 | // Obtain the position of the button
274 | final WidgetPosition widgetPosition = WidgetPosition.fromContext(context);
275 |
276 | // Compute the dimensions of the container
277 | final double containerWidth =
278 | widget.icons.length * (widget.iconWidth + widget.iconPadding) +
279 | widget.iconWidth * widget.iconGrowRatio;
280 | final double containerHeight = widget.iconWidth * widget.iconGrowRatio;
281 |
282 | // Compute the final left position
283 | double left;
284 |
285 | // If the button is located on the right side of the screen, prefer
286 | // trying to align the right edges (button and container)
287 | if (widgetPosition.xPositionInViewport > widgetPosition.viewportWidth / 2) {
288 | left = (widgetPosition.xPositionInViewport +
289 | widgetPosition.rect.width -
290 | containerWidth)
291 | .clamp(0.0, double.infinity);
292 | } else {
293 | left = (widgetPosition.xPositionInViewport + containerWidth);
294 | if (left > widgetPosition.viewportWidth) {
295 | left = (widgetPosition.viewportWidth - containerWidth)
296 | .clamp(0.0, double.infinity);
297 | }
298 | }
299 |
300 | // Compute the final top position
301 | double top;
302 |
303 | // If there is enough space above the button and the user wants to display it above
304 | if (widget.containerAbove) {
305 | final double roomAbove =
306 | widgetPosition.rect.top - containerHeight - widget.containerPadding;
307 | if (roomAbove >= 0) {
308 | top = widgetPosition.yPositionInViewport -
309 | containerHeight -
310 | widget.containerPadding;
311 | } else {
312 | // There is not enough space, so display it below
313 | top = widgetPosition.yPositionInViewport +
314 | widgetPosition.rect.height +
315 | widget.containerPadding;
316 | }
317 | } else {
318 | final double roomBelow = widgetPosition.viewportHeight -
319 | (widgetPosition.rect.bottom +
320 | containerHeight +
321 | widget.containerPadding);
322 | if (roomBelow >= 0) {
323 | top = widgetPosition.yPositionInViewport +
324 | widgetPosition.rect.height +
325 | widget.containerPadding;
326 | } else {
327 | // There is not enough space, so display it above
328 | top = widgetPosition.yPositionInViewport -
329 | containerHeight -
330 | widget.containerPadding;
331 | }
332 | }
333 |
334 | return Offset(left, top);
335 | }
336 |
337 | //
338 | // A message has been sent by an icon to indicate whether
339 | // it is highlighted or not
340 | //
341 | void _onIconSelectionChange(ReactiveIconSelectionMessage message) {
342 | if (identical(_selectedButton, message.icon)) {
343 | if (!message.isSelected) {
344 | _selectedButton = null;
345 | }
346 | } else {
347 | if (message.isSelected) {
348 | _selectedButton = message.icon;
349 | }
350 | }
351 | }
352 | }
353 |
--------------------------------------------------------------------------------
/lib/src/reactive_button_bloc.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 | import 'package:flutter/material.dart';
3 | import 'package:rxdart/rxdart.dart';
4 | import 'package:flutter_reactive_button/src/reactive_icon_selection_message.dart';
5 |
6 | class ReactiveButtonBloc {
7 | //
8 | // Stream that allows broadcasting the pointer movements
9 | //
10 | PublishSubject _pointerPositionController = PublishSubject();
11 | Sink get inPointerPosition => _pointerPositionController.sink;
12 | Observable get outPointerPosition =>
13 | _pointerPositionController.stream;
14 |
15 | //
16 | // Stream that allows broadcasting the icons selection
17 | //
18 | PublishSubject _iconSelectionController =
19 | PublishSubject();
20 | Sink get inIconSelection =>
21 | _iconSelectionController.sink;
22 | Stream get outIconSelection =>
23 | _iconSelectionController.stream;
24 |
25 | //
26 | // Dispose the resources
27 | //
28 | void dispose() {
29 | _iconSelectionController.close();
30 | _pointerPositionController.close();
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/lib/src/reactive_icon.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 |
3 | import 'package:flutter/material.dart';
4 | import 'package:flutter_reactive_button/src/reactive_button_bloc.dart';
5 | import 'package:flutter_reactive_button/src/reactive_icon_definition.dart';
6 | import 'package:flutter_reactive_button/src/reactive_icon_selection_message.dart';
7 | import 'package:flutter_reactive_button/src/widget_position.dart';
8 |
9 | class ReactiveIcon extends StatefulWidget {
10 | ReactiveIcon({
11 | Key key,
12 | this.iconWidth,
13 | this.icon,
14 | this.bloc,
15 | this.growRatio,
16 | this.roundIcon,
17 | }) : super(key: key);
18 |
19 | /// Width of each individual icons
20 | final double iconWidth;
21 |
22 | /// The icon definition
23 | final ReactiveIconDefinition icon;
24 |
25 | /// The BLoC to handle events
26 | final ReactiveButtonBloc bloc;
27 |
28 | /// The ratio to be used to highlight an icon when hovered
29 | final double growRatio;
30 |
31 | /// Is the icon round
32 | final bool roundIcon;
33 |
34 | @override
35 | _ReactiveIconState createState() => _ReactiveIconState();
36 | }
37 |
38 | class _ReactiveIconState extends State
39 | with SingleTickerProviderStateMixin {
40 | StreamSubscription _streamSubscription;
41 | AnimationController _animationController;
42 |
43 | // Flag to know whether this icon is currently hovered
44 | bool _isHovered = false;
45 |
46 | @override
47 | void initState() {
48 | super.initState();
49 |
50 | // Reset
51 | _isHovered = false;
52 |
53 | // Initialize the animation to highlight the hovered icon
54 | _animationController = AnimationController(
55 | value: 0.0,
56 | duration: const Duration(milliseconds: 200),
57 | vsync: this,
58 | )..addListener(() {
59 | setState(() {});
60 | });
61 |
62 | // Start listening to pointer position changes
63 | _streamSubscription = widget.bloc.outPointerPosition
64 | // take some time before jumping into the request (there might be several ones in a row)
65 | .bufferTime(Duration(milliseconds: 100))
66 | // and, do not update where this is no need
67 | .where((batch) => batch.isNotEmpty)
68 | .listen(_onPointerPositionChanged);
69 | }
70 |
71 | @override
72 | void dispose() {
73 | _animationController?.dispose();
74 | _streamSubscription?.cancel();
75 | super.dispose();
76 | }
77 |
78 | @override
79 | Widget build(BuildContext context) {
80 | Widget icon = Image.asset(
81 | widget.icon.assetIcon,
82 | width: widget.iconWidth,
83 | height: widget.iconWidth,
84 | );
85 | if (widget.roundIcon) {
86 | icon = CircleAvatar(
87 | radius: widget.iconWidth / 2,
88 | child: icon,
89 | backgroundColor: Colors.black12,
90 | );
91 | }
92 |
93 | return Transform.scale(
94 | scale: 1.0 + _animationController.value * widget.growRatio,
95 | alignment: Alignment.center,
96 | child: icon,
97 | );
98 | }
99 |
100 | //
101 | // The pointer position has changed
102 | // We need to check whether it hovers this icon
103 | // If yes, we need to highlight this icon (if not yet done)
104 | // If no, we need to remove any highlight
105 | // Also, we need to notify whomever interested in knowning
106 | // which icon is highlighted or lost its highlight
107 | //
108 | void _onPointerPositionChanged(List position) {
109 | WidgetPosition widgetPosition = WidgetPosition.fromContext(context);
110 | bool isHit = widgetPosition.rect.contains(position.last);
111 | if (isHit) {
112 | if (!_isHovered) {
113 | _isHovered = true;
114 | _animationController.forward();
115 | _sendNotification();
116 | }
117 | } else {
118 | if (_isHovered) {
119 | _isHovered = false;
120 | _animationController.reverse();
121 | _sendNotification();
122 | }
123 | }
124 | }
125 |
126 | //
127 | // Send a notification to whomever is interesting
128 | // in knowning the current status of this icon
129 | //
130 | void _sendNotification() {
131 | widget.bloc.inIconSelection.add(ReactiveIconSelectionMessage(
132 | icon: widget.icon,
133 | isSelected: _isHovered,
134 | ));
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/lib/src/reactive_icon_container.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:flutter_reactive_button/src/reactive_button_bloc.dart';
3 | import 'package:flutter_reactive_button/src/reactive_icon.dart';
4 | import 'package:flutter_reactive_button/src/reactive_icon_definition.dart';
5 |
6 | class ReactiveIconContainer extends StatefulWidget {
7 | ReactiveIconContainer({
8 | Key key,
9 | @required this.icons,
10 | @required this.position,
11 | @required this.bloc,
12 | @required this.iconWidth,
13 | @required this.decoration,
14 | @required this.padding,
15 | @required this.roundIcons,
16 | @required this.iconPadding,
17 | @required this.iconGrowRatio,
18 | }) : super(key: key);
19 |
20 | /// List of image assets to be used
21 | final List icons;
22 |
23 | /// Width of each individual icons
24 | final double iconWidth;
25 |
26 | /// The BLoC to handle events
27 | final ReactiveButtonBloc bloc;
28 |
29 | /// The position of the icons container
30 | final Offset position;
31 |
32 | /// Decoration of the container
33 | final Decoration decoration;
34 |
35 | /// Padding of the container
36 | final EdgeInsets padding;
37 |
38 | /// Shape of the icons. Are they round?
39 | final bool roundIcons;
40 |
41 | /// Padding between icons
42 | final double iconPadding;
43 |
44 | /// Icon grow ratio when hovered
45 | final double iconGrowRatio;
46 |
47 | @override
48 | _ReactiveIconContainerState createState() {
49 | return new _ReactiveIconContainerState();
50 | }
51 | }
52 |
53 | class _ReactiveIconContainerState extends State {
54 | @override
55 | Widget build(BuildContext context) {
56 | return Positioned(
57 | top: widget.position.dy,
58 | left: widget.position.dx,
59 | child: _buildIconsContainer(),
60 | );
61 | }
62 |
63 | Widget _buildIconsContainer() {
64 | final double containerWidth =
65 | widget.icons.length * (widget.iconWidth + widget.iconPadding) +
66 | widget.iconWidth * widget.iconGrowRatio;
67 | final double containerHeight = widget.iconWidth * widget.iconGrowRatio;
68 |
69 | return ConstrainedBox(
70 | constraints: BoxConstraints.tight(Size(containerWidth, containerHeight)),
71 | child: Container(
72 | decoration: widget.decoration,
73 | padding: widget.padding,
74 | child: Row(
75 | mainAxisAlignment: MainAxisAlignment.spaceAround,
76 | children: widget.icons.map((ReactiveIconDefinition icon) {
77 | return ReactiveIcon(
78 | icon: icon,
79 | bloc: widget.bloc,
80 | iconWidth: widget.iconWidth,
81 | growRatio: widget.iconGrowRatio,
82 | roundIcon: widget.roundIcons,
83 | );
84 | }).toList(),
85 | ),
86 | ),
87 | );
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/lib/src/reactive_icon_definition.dart:
--------------------------------------------------------------------------------
1 | class ReactiveIconDefinition {
2 | final String assetIcon;
3 | final String code;
4 |
5 | ReactiveIconDefinition({
6 | this.assetIcon,
7 | this.code: '',
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/lib/src/reactive_icon_selection_message.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter_reactive_button/src/reactive_icon_definition.dart';
2 |
3 | class ReactiveIconSelectionMessage {
4 | ReactiveIconSelectionMessage({
5 | this.icon,
6 | this.isSelected,
7 | });
8 |
9 | final ReactiveIconDefinition icon;
10 | final bool isSelected;
11 | }
12 |
--------------------------------------------------------------------------------
/lib/src/widget_position.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:flutter/rendering.dart';
3 |
4 | ///
5 | /// Helper class to determine the position of a widget (via its BuildContext) in the Viewport,
6 | /// considering the case where the screen might be larger than the viewport.
7 | /// In this case, we need to consider the scrolling offset(s).
8 | ///
9 | class WidgetPosition {
10 | double xPositionInViewport;
11 | double yPositionInViewport;
12 | double viewportWidth;
13 | double viewportHeight;
14 | bool isInScrollable = false;
15 | Axis scrollableAxis;
16 | double scrollAreaMax;
17 | double positionInScrollArea;
18 | Rect rect;
19 |
20 | WidgetPosition({
21 | this.xPositionInViewport,
22 | this.yPositionInViewport,
23 | this.viewportWidth,
24 | this.viewportHeight,
25 | this.isInScrollable : false,
26 | this.scrollableAxis,
27 | this.scrollAreaMax,
28 | this.positionInScrollArea,
29 | this.rect,
30 | });
31 |
32 | WidgetPosition.fromContext(BuildContext context){
33 | // Obtain the button RenderObject
34 | final RenderObject object = context.findRenderObject();
35 | // Get the physical dimensions and position of the button in the Viewport
36 | final translation = object?.getTransformTo(null)?.getTranslation();
37 | // Get the potential Viewport (case of scroll area)
38 | final RenderAbstractViewport viewport = RenderAbstractViewport.of(object);
39 | // Get the device dimensions and properties
40 | final MediaQueryData mediaQueryData = MediaQuery.of(context);
41 | // Get the Scroll area state (if any)
42 | final ScrollableState scrollableState = Scrollable.of(context);
43 | // Get the physical dimensions and dimensions on the Screen
44 | final Size size = object?.semanticBounds?.size;
45 |
46 | xPositionInViewport = translation.x;
47 | yPositionInViewport = translation.y;
48 | viewportWidth = mediaQueryData.size.width;
49 | viewportHeight = mediaQueryData.size.height;
50 | rect = Rect.fromLTWH(translation.x, translation.y, size.width, size.height);
51 |
52 | // If viewport exists, this means that we are inside a Scrolling area
53 | // Take this opportunity to get the characteristics of that Scrolling area
54 | if (viewport != null){
55 | final ScrollPosition position = scrollableState.position;
56 | final RevealedOffset vpOffset = viewport.getOffsetToReveal(object, 0.0);
57 |
58 | isInScrollable = true;
59 | scrollAreaMax = position.maxScrollExtent;
60 | positionInScrollArea = vpOffset.offset;
61 | scrollableAxis = scrollableState.widget.axis;
62 | }
63 | }
64 |
65 | @override
66 | String toString(){
67 | return 'X,Y in VP: $xPositionInViewport,$yPositionInViewport VP dimensions: $viewportWidth,$viewportHeight ScrollArea max: $scrollAreaMax X/Y in scroll: $positionInScrollArea ScrollAxis: $scrollableAxis';
68 | }
69 | }
--------------------------------------------------------------------------------
/pubspec.yaml:
--------------------------------------------------------------------------------
1 | name: flutter_reactive_button
2 | description: a Widget to mimic the Facebook Reaction button in Flutter and allows re-use in different other use cases than Facebook.
3 | version: 1.0.0
4 | author: Didier Boelens
5 | homepage: https://github.com/boeledi/flutter_reactive_button
6 |
7 | environment:
8 | sdk: ">=1.19.0 <3.0.0"
9 |
10 | dependencies:
11 | flutter:
12 | sdk: flutter
13 |
14 | rxdart: ^0.18.1
15 |
16 | dev_dependencies:
17 | flutter_test:
18 | sdk: flutter
19 |
20 | flutter:
21 |
22 | # To add assets to your package, add an assets section, like this:
23 | # assets:
24 | # - images/a_dot_burr.jpeg
25 | # - images/a_dot_ham.jpeg
26 |
--------------------------------------------------------------------------------
/reactive_button.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boeledi/flutter_reactive_button/b937c00bad4a4aec705324ff56c7247ce57039f3/reactive_button.gif
--------------------------------------------------------------------------------
/test/flutter_reactive_button_test.dart:
--------------------------------------------------------------------------------
1 | void main() {
2 | //TODO
3 | }
4 |
--------------------------------------------------------------------------------