├── .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 | Flutter ReactiveButton 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 | --------------------------------------------------------------------------------