1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/// @docImport 'package:flutter/rendering.dart';
6///
7/// @docImport 'scroll_controller.dart';
8/// @docImport 'scroll_physics.dart';
9/// @docImport 'scroll_position.dart';
10/// @docImport 'scroll_position_with_single_context.dart';
11/// @docImport 'scrollable.dart';
12library;
13
14import 'dart:async';
15import 'dart:math' as math;
16
17import 'package:flutter/foundation.dart';
18import 'package:flutter/gestures.dart';
19import 'package:flutter/rendering.dart';
20
21import 'basic.dart';
22import 'framework.dart';
23import 'scroll_metrics.dart';
24import 'scroll_notification.dart';
25
26/// A backend for a [ScrollActivity].
27///
28/// Used by subclasses of [ScrollActivity] to manipulate the scroll view that
29/// they are acting upon.
30///
31/// See also:
32///
33/// * [ScrollActivity], which uses this class as its delegate.
34/// * [ScrollPositionWithSingleContext], the main implementation of this interface.
35abstract class ScrollActivityDelegate {
36 /// The direction in which the scroll view scrolls.
37 AxisDirection get axisDirection;
38
39 /// Update the scroll position to the given pixel value.
40 ///
41 /// Returns the overscroll, if any. See [ScrollPosition.setPixels] for more
42 /// information.
43 double setPixels(double pixels);
44
45 /// Updates the scroll position by the given amount.
46 ///
47 /// Appropriate for when the user is directly manipulating the scroll
48 /// position, for example by dragging the scroll view. Typically applies
49 /// [ScrollPhysics.applyPhysicsToUserOffset] and other transformations that
50 /// are appropriate for user-driving scrolling.
51 void applyUserOffset(double delta);
52
53 /// Terminate the current activity and start an idle activity.
54 void goIdle();
55
56 /// Terminate the current activity and start a ballistic activity with the
57 /// given velocity.
58 void goBallistic(double velocity);
59}
60
61/// Base class for scrolling activities like dragging and flinging.
62///
63/// See also:
64///
65/// * [ScrollPosition], which uses [ScrollActivity] objects to manage the
66/// [ScrollPosition] of a [Scrollable].
67abstract class ScrollActivity {
68 /// Initializes [delegate] for subclasses.
69 ScrollActivity(this._delegate) {
70 assert(debugMaybeDispatchCreated('widgets', 'ScrollActivity', this));
71 }
72
73 /// The delegate that this activity will use to actuate the scroll view.
74 ScrollActivityDelegate get delegate => _delegate;
75 ScrollActivityDelegate _delegate;
76
77 bool _isDisposed = false;
78
79 /// Updates the activity's link to the [ScrollActivityDelegate].
80 ///
81 /// This should only be called when an activity is being moved from a defunct
82 /// (or about-to-be defunct) [ScrollActivityDelegate] object to a new one.
83 void updateDelegate(ScrollActivityDelegate value) {
84 assert(_delegate != value);
85 _delegate = value;
86 }
87
88 /// Called by the [ScrollActivityDelegate] when it has changed type (for
89 /// example, when changing from an Android-style scroll position to an
90 /// iOS-style scroll position). If this activity can differ between the two
91 /// modes, then it should tell the position to restart that activity
92 /// appropriately.
93 ///
94 /// For example, [BallisticScrollActivity]'s implementation calls
95 /// [ScrollActivityDelegate.goBallistic].
96 void resetActivity() {}
97
98 /// Dispatch a [ScrollStartNotification] with the given metrics.
99 void dispatchScrollStartNotification(ScrollMetrics metrics, BuildContext? context) {
100 ScrollStartNotification(metrics: metrics, context: context).dispatch(context);
101 }
102
103 /// Dispatch a [ScrollUpdateNotification] with the given metrics and scroll delta.
104 void dispatchScrollUpdateNotification(
105 ScrollMetrics metrics,
106 BuildContext context,
107 double scrollDelta,
108 ) {
109 ScrollUpdateNotification(
110 metrics: metrics,
111 context: context,
112 scrollDelta: scrollDelta,
113 ).dispatch(context);
114 }
115
116 /// Dispatch an [OverscrollNotification] with the given metrics and overscroll.
117 void dispatchOverscrollNotification(
118 ScrollMetrics metrics,
119 BuildContext context,
120 double overscroll,
121 ) {
122 OverscrollNotification(
123 metrics: metrics,
124 context: context,
125 overscroll: overscroll,
126 ).dispatch(context);
127 }
128
129 /// Dispatch a [ScrollEndNotification] with the given metrics and overscroll.
130 void dispatchScrollEndNotification(ScrollMetrics metrics, BuildContext context) {
131 ScrollEndNotification(metrics: metrics, context: context).dispatch(context);
132 }
133
134 /// Called when the scroll view that is performing this activity changes its metrics.
135 void applyNewDimensions() {}
136
137 /// Whether the scroll view should ignore pointer events while performing this
138 /// activity.
139 ///
140 /// See also:
141 ///
142 /// * [isScrolling], which describes whether the activity is considered
143 /// to represent user interaction or not.
144 bool get shouldIgnorePointer;
145
146 /// Whether performing this activity constitutes scrolling.
147 ///
148 /// Used, for example, to determine whether the user scroll
149 /// direction (see [ScrollPosition.userScrollDirection]) is
150 /// [ScrollDirection.idle].
151 ///
152 /// See also:
153 ///
154 /// * [shouldIgnorePointer], which controls whether pointer events
155 /// are allowed while the activity is live.
156 /// * [UserScrollNotification], which exposes this status.
157 bool get isScrolling;
158
159 /// If applicable, the velocity at which the scroll offset is currently
160 /// independently changing (i.e. without external stimuli such as a dragging
161 /// gestures) in logical pixels per second for this activity.
162 double get velocity;
163
164 /// Called when the scroll view stops performing this activity.
165 @mustCallSuper
166 void dispose() {
167 assert(debugMaybeDispatchDisposed(this));
168 _isDisposed = true;
169 }
170
171 @override
172 String toString() => describeIdentity(this);
173}
174
175/// A scroll activity that does nothing.
176///
177/// When a scroll view is not scrolling, it is performing the idle activity.
178///
179/// If the [Scrollable] changes dimensions, this activity triggers a ballistic
180/// activity to restore the view.
181class IdleScrollActivity extends ScrollActivity {
182 /// Creates a scroll activity that does nothing.
183 IdleScrollActivity(super.delegate);
184
185 @override
186 void applyNewDimensions() {
187 delegate.goBallistic(0.0);
188 }
189
190 @override
191 bool get shouldIgnorePointer => false;
192
193 @override
194 bool get isScrolling => false;
195
196 @override
197 double get velocity => 0.0;
198}
199
200/// Interface for holding a [Scrollable] stationary.
201///
202/// An object that implements this interface is returned by
203/// [ScrollPosition.hold]. It holds the scrollable stationary until an activity
204/// is started or the [cancel] method is called.
205abstract class ScrollHoldController {
206 /// Release the [Scrollable], potentially letting it go ballistic if
207 /// necessary.
208 void cancel();
209}
210
211/// A scroll activity that does nothing but can be released to resume
212/// normal idle behavior.
213///
214/// This is used while the user is touching the [Scrollable] but before the
215/// touch has become a [Drag].
216///
217/// For the purposes of [ScrollNotification]s, this activity does not constitute
218/// scrolling, and does not prevent the user from interacting with the contents
219/// of the [Scrollable] (unlike when a drag has begun or there is a scroll
220/// animation underway).
221class HoldScrollActivity extends ScrollActivity implements ScrollHoldController {
222 /// Creates a scroll activity that does nothing.
223 HoldScrollActivity({required ScrollActivityDelegate delegate, this.onHoldCanceled})
224 : super(delegate);
225
226 /// Called when [dispose] is called.
227 final VoidCallback? onHoldCanceled;
228
229 @override
230 bool get shouldIgnorePointer => false;
231
232 @override
233 bool get isScrolling => false;
234
235 @override
236 double get velocity => 0.0;
237
238 @override
239 void cancel() {
240 delegate.goBallistic(0.0);
241 }
242
243 @override
244 void dispose() {
245 onHoldCanceled?.call();
246 super.dispose();
247 }
248}
249
250/// Scrolls a scroll view as the user drags their finger across the screen.
251///
252/// See also:
253///
254/// * [DragScrollActivity], which is the activity the scroll view performs
255/// while a drag is underway.
256class ScrollDragController implements Drag {
257 /// Creates an object that scrolls a scroll view as the user drags their
258 /// finger across the screen.
259 ScrollDragController({
260 required ScrollActivityDelegate delegate,
261 required DragStartDetails details,
262 this.onDragCanceled,
263 this.carriedVelocity,
264 this.motionStartDistanceThreshold,
265 }) : assert(
266 motionStartDistanceThreshold == null || motionStartDistanceThreshold > 0.0,
267 'motionStartDistanceThreshold must be a positive number or null',
268 ),
269 _delegate = delegate,
270 _lastDetails = details,
271 _retainMomentum = carriedVelocity != null && carriedVelocity != 0.0,
272 _lastNonStationaryTimestamp = details.sourceTimeStamp,
273 _kind = details.kind,
274 _offsetSinceLastStop = motionStartDistanceThreshold == null ? null : 0.0 {
275 assert(debugMaybeDispatchCreated('widgets', 'ScrollDragController', this));
276 }
277
278 /// The object that will actuate the scroll view as the user drags.
279 ScrollActivityDelegate get delegate => _delegate;
280 ScrollActivityDelegate _delegate;
281
282 /// Called when [dispose] is called.
283 final VoidCallback? onDragCanceled;
284
285 /// Velocity that was present from a previous [ScrollActivity] when this drag
286 /// began.
287 final double? carriedVelocity;
288
289 /// Amount of pixels in either direction the drag has to move by to start
290 /// scroll movement again after each time scrolling came to a stop.
291 final double? motionStartDistanceThreshold;
292
293 Duration? _lastNonStationaryTimestamp;
294 bool _retainMomentum;
295
296 /// Null if already in motion or has no [motionStartDistanceThreshold].
297 double? _offsetSinceLastStop;
298
299 /// Maximum amount of time interval the drag can have consecutive stationary
300 /// pointer update events before losing the momentum carried from a previous
301 /// scroll activity.
302 static const Duration momentumRetainStationaryDurationThreshold = Duration(milliseconds: 20);
303
304 /// The minimum amount of velocity needed to apply the [carriedVelocity] at
305 /// the end of a drag. Expressed as a factor. For example with a
306 /// [carriedVelocity] of 2000, we will need a velocity of at least 1000 to
307 /// apply the [carriedVelocity] as well. If the velocity does not meet the
308 /// threshold, the [carriedVelocity] is lost. Decided by fair eyeballing
309 /// with the scroll_overlay platform test.
310 static const double momentumRetainVelocityThresholdFactor = 0.5;
311
312 /// Maximum amount of time interval the drag can have consecutive stationary
313 /// pointer update events before needing to break the
314 /// [motionStartDistanceThreshold] to start motion again.
315 static const Duration motionStoppedDurationThreshold = Duration(milliseconds: 50);
316
317 /// The drag distance past which, a [motionStartDistanceThreshold] breaking
318 /// drag is considered a deliberate fling.
319 static const double _bigThresholdBreakDistance = 24.0;
320
321 bool get _reversed => axisDirectionIsReversed(delegate.axisDirection);
322
323 /// Updates the controller's link to the [ScrollActivityDelegate].
324 ///
325 /// This should only be called when a controller is being moved from a defunct
326 /// (or about-to-be defunct) [ScrollActivityDelegate] object to a new one.
327 void updateDelegate(ScrollActivityDelegate value) {
328 assert(_delegate != value);
329 _delegate = value;
330 }
331
332 /// Determines whether to lose the existing incoming velocity when starting
333 /// the drag.
334 void _maybeLoseMomentum(double offset, Duration? timestamp) {
335 if (_retainMomentum &&
336 offset == 0.0 &&
337 (timestamp == null || // If drag event has no timestamp, we lose momentum.
338 timestamp - _lastNonStationaryTimestamp! > momentumRetainStationaryDurationThreshold)) {
339 // If pointer is stationary for too long, we lose momentum.
340 _retainMomentum = false;
341 }
342 }
343
344 /// If a motion start threshold exists, determine whether the threshold needs
345 /// to be broken to scroll. Also possibly apply an offset adjustment when
346 /// threshold is first broken.
347 ///
348 /// Returns `0.0` when stationary or within threshold. Returns `offset`
349 /// transparently when already in motion.
350 double _adjustForScrollStartThreshold(double offset, Duration? timestamp) {
351 if (timestamp == null) {
352 // If we can't track time, we can't apply thresholds.
353 // May be null for proxied drags like via accessibility.
354 return offset;
355 }
356 if (offset == 0.0) {
357 if (motionStartDistanceThreshold != null &&
358 _offsetSinceLastStop == null &&
359 timestamp - _lastNonStationaryTimestamp! > motionStoppedDurationThreshold) {
360 // Enforce a new threshold.
361 _offsetSinceLastStop = 0.0;
362 }
363 // Not moving can't break threshold.
364 return 0.0;
365 } else {
366 if (_offsetSinceLastStop == null) {
367 // Already in motion or no threshold behavior configured such as for
368 // Android. Allow transparent offset transmission.
369 return offset;
370 } else {
371 _offsetSinceLastStop = _offsetSinceLastStop! + offset;
372 if (_offsetSinceLastStop!.abs() > motionStartDistanceThreshold!) {
373 // Threshold broken.
374 _offsetSinceLastStop = null;
375 if (offset.abs() > _bigThresholdBreakDistance) {
376 // This is heuristically a very deliberate fling. Leave the motion
377 // unaffected.
378 return offset;
379 } else {
380 // This is a normal speed threshold break.
381 return math.min(
382 // Ease into the motion when the threshold is initially broken
383 // to avoid a visible jump.
384 motionStartDistanceThreshold! / 3.0,
385 offset.abs(),
386 ) *
387 offset.sign;
388 }
389 } else {
390 return 0.0;
391 }
392 }
393 }
394 }
395
396 @override
397 void update(DragUpdateDetails details) {
398 assert(details.primaryDelta != null);
399 _lastDetails = details;
400 double offset = details.primaryDelta!;
401 if (offset != 0.0) {
402 _lastNonStationaryTimestamp = details.sourceTimeStamp;
403 }
404 // By default, iOS platforms carries momentum and has a start threshold
405 // (configured in [BouncingScrollPhysics]). The 2 operations below are
406 // no-ops on Android.
407 _maybeLoseMomentum(offset, details.sourceTimeStamp);
408 offset = _adjustForScrollStartThreshold(offset, details.sourceTimeStamp);
409 if (offset == 0.0) {
410 return;
411 }
412 if (_reversed) {
413 offset = -offset;
414 }
415 delegate.applyUserOffset(offset);
416 }
417
418 @override
419 void end(DragEndDetails details) {
420 assert(details.primaryVelocity != null);
421 // We negate the velocity here because if the touch is moving downwards,
422 // the scroll has to move upwards. It's the same reason that update()
423 // above negates the delta before applying it to the scroll offset.
424 double velocity = -details.primaryVelocity!;
425 if (_reversed) {
426 velocity = -velocity;
427 }
428 _lastDetails = details;
429
430 if (_retainMomentum) {
431 // Build momentum only if dragging in the same direction.
432 final bool isFlingingInSameDirection = velocity.sign == carriedVelocity!.sign;
433 // Build momentum only if the velocity of the last drag was not
434 // substantially lower than the carried momentum.
435 final bool isVelocityNotSubstantiallyLessThanCarriedMomentum =
436 velocity.abs() > carriedVelocity!.abs() * momentumRetainVelocityThresholdFactor;
437 if (isFlingingInSameDirection && isVelocityNotSubstantiallyLessThanCarriedMomentum) {
438 velocity += carriedVelocity!;
439 }
440 }
441 delegate.goBallistic(velocity);
442 }
443
444 @override
445 void cancel() {
446 delegate.goBallistic(0.0);
447 }
448
449 /// Called by the delegate when it is no longer sending events to this object.
450 @mustCallSuper
451 void dispose() {
452 assert(debugMaybeDispatchDisposed(this));
453 _lastDetails = null;
454 onDragCanceled?.call();
455 }
456
457 /// The type of input device driving the drag.
458 final PointerDeviceKind? _kind;
459
460 /// The most recently observed [DragStartDetails], [DragUpdateDetails], or
461 /// [DragEndDetails] object.
462 dynamic get lastDetails => _lastDetails;
463 dynamic _lastDetails;
464
465 @override
466 String toString() => describeIdentity(this);
467}
468
469/// The activity a scroll view performs when the user drags their finger
470/// across the screen.
471///
472/// See also:
473///
474/// * [ScrollDragController], which listens to the [Drag] and actually scrolls
475/// the scroll view.
476class DragScrollActivity extends ScrollActivity {
477 /// Creates an activity for when the user drags their finger across the
478 /// screen.
479 DragScrollActivity(super.delegate, ScrollDragController controller) : _controller = controller;
480
481 ScrollDragController? _controller;
482
483 @override
484 void dispatchScrollStartNotification(ScrollMetrics metrics, BuildContext? context) {
485 final dynamic lastDetails = _controller!.lastDetails;
486 assert(lastDetails is DragStartDetails);
487 ScrollStartNotification(
488 metrics: metrics,
489 context: context,
490 dragDetails: lastDetails as DragStartDetails,
491 ).dispatch(context);
492 }
493
494 @override
495 void dispatchScrollUpdateNotification(
496 ScrollMetrics metrics,
497 BuildContext context,
498 double scrollDelta,
499 ) {
500 final dynamic lastDetails = _controller!.lastDetails;
501 assert(lastDetails is DragUpdateDetails);
502 ScrollUpdateNotification(
503 metrics: metrics,
504 context: context,
505 scrollDelta: scrollDelta,
506 dragDetails: lastDetails as DragUpdateDetails,
507 ).dispatch(context);
508 }
509
510 @override
511 void dispatchOverscrollNotification(
512 ScrollMetrics metrics,
513 BuildContext context,
514 double overscroll,
515 ) {
516 final dynamic lastDetails = _controller!.lastDetails;
517 assert(lastDetails is DragUpdateDetails);
518 OverscrollNotification(
519 metrics: metrics,
520 context: context,
521 overscroll: overscroll,
522 dragDetails: lastDetails as DragUpdateDetails,
523 ).dispatch(context);
524 }
525
526 @override
527 void dispatchScrollEndNotification(ScrollMetrics metrics, BuildContext context) {
528 // We might not have DragEndDetails yet if we're being called from beginActivity.
529 final dynamic lastDetails = _controller!.lastDetails;
530 ScrollEndNotification(
531 metrics: metrics,
532 context: context,
533 dragDetails: lastDetails is DragEndDetails ? lastDetails : null,
534 ).dispatch(context);
535 }
536
537 @override
538 bool get shouldIgnorePointer => _controller?._kind != PointerDeviceKind.trackpad;
539
540 @override
541 bool get isScrolling => true;
542
543 // DragScrollActivity is not independently changing velocity yet
544 // until the drag is ended.
545 @override
546 double get velocity => 0.0;
547
548 @override
549 void dispose() {
550 _controller = null;
551 super.dispose();
552 }
553
554 @override
555 String toString() {
556 return '${describeIdentity(this)}($_controller)';
557 }
558}
559
560/// An activity that animates a scroll view based on a physics [Simulation].
561///
562/// A [BallisticScrollActivity] is typically used when the user lifts their
563/// finger off the screen to continue the scrolling gesture with the current velocity.
564///
565/// [BallisticScrollActivity] is also used to restore a scroll view to a valid
566/// scroll offset when the geometry of the scroll view changes. In these
567/// situations, the [Simulation] typically starts with a zero velocity.
568///
569/// See also:
570///
571/// * [DrivenScrollActivity], which animates a scroll view based on a set of
572/// animation parameters.
573class BallisticScrollActivity extends ScrollActivity {
574 /// Creates an activity that animates a scroll view based on a [simulation].
575 BallisticScrollActivity(
576 super.delegate,
577 Simulation simulation,
578 TickerProvider vsync,
579 this.shouldIgnorePointer,
580 ) {
581 _controller =
582 AnimationController.unbounded(
583 debugLabel: kDebugMode ? objectRuntimeType(this, 'BallisticScrollActivity') : null,
584 vsync: vsync,
585 )
586 ..addListener(_tick)
587 ..animateWith(
588 simulation,
589 ).whenComplete(_end); // won't trigger if we dispose _controller before it completes.
590 }
591
592 late AnimationController _controller;
593
594 @override
595 void resetActivity() {
596 delegate.goBallistic(velocity);
597 }
598
599 @override
600 void applyNewDimensions() {
601 delegate.goBallistic(velocity);
602 }
603
604 void _tick() {
605 if (!applyMoveTo(_controller.value)) {
606 delegate.goIdle();
607 }
608 }
609
610 /// Move the position to the given location.
611 ///
612 /// If the new position was fully applied, returns true. If there was any
613 /// overflow, returns false.
614 ///
615 /// The default implementation calls [ScrollActivityDelegate.setPixels]
616 /// and returns true if the overflow was zero.
617 @protected
618 bool applyMoveTo(double value) {
619 return delegate.setPixels(value).abs() < precisionErrorTolerance;
620 }
621
622 void _end() {
623 // Check if the activity was disposed before going ballistic because _end might be called
624 // if _controller is disposed just after completion.
625 if (!_isDisposed) {
626 delegate.goBallistic(0.0);
627 }
628 }
629
630 @override
631 void dispatchOverscrollNotification(
632 ScrollMetrics metrics,
633 BuildContext context,
634 double overscroll,
635 ) {
636 OverscrollNotification(
637 metrics: metrics,
638 context: context,
639 overscroll: overscroll,
640 velocity: velocity,
641 ).dispatch(context);
642 }
643
644 @override
645 final bool shouldIgnorePointer;
646
647 @override
648 bool get isScrolling => true;
649
650 @override
651 double get velocity => _controller.velocity;
652
653 @override
654 void dispose() {
655 _controller.dispose();
656 super.dispose();
657 }
658
659 @override
660 String toString() {
661 return '${describeIdentity(this)}($_controller)';
662 }
663}
664
665/// An activity that animates a scroll view based on animation parameters.
666///
667/// For example, a [DrivenScrollActivity] is used to implement
668/// [ScrollController.animateTo].
669///
670/// See also:
671///
672/// * [BallisticScrollActivity], which animates a scroll view based on a
673/// physics [Simulation].
674class DrivenScrollActivity extends ScrollActivity {
675 /// Creates an activity that animates a scroll view based on animation
676 /// parameters.
677 DrivenScrollActivity(
678 super.delegate, {
679 required double from,
680 required double to,
681 required Duration duration,
682 required Curve curve,
683 required TickerProvider vsync,
684 }) : assert(duration > Duration.zero) {
685 _completer = Completer<void>();
686 _controller =
687 AnimationController.unbounded(
688 value: from,
689 debugLabel: objectRuntimeType(this, 'DrivenScrollActivity'),
690 vsync: vsync,
691 )
692 ..addListener(_tick)
693 ..animateTo(
694 to,
695 duration: duration,
696 curve: curve,
697 ).whenComplete(_end); // won't trigger if we dispose _controller before it completes.
698 }
699
700 late final Completer<void> _completer;
701 late final AnimationController _controller;
702
703 /// A [Future] that completes when the activity stops.
704 ///
705 /// For example, this [Future] will complete if the animation reaches the end
706 /// or if the user interacts with the scroll view in way that causes the
707 /// animation to stop before it reaches the end.
708 Future<void> get done => _completer.future;
709
710 void _tick() {
711 if (delegate.setPixels(_controller.value) != 0.0) {
712 delegate.goIdle();
713 }
714 }
715
716 void _end() {
717 // Check if the activity was disposed before going ballistic because _end might be called
718 // if _controller is disposed just after completion.
719 if (!_isDisposed) {
720 delegate.goBallistic(velocity);
721 }
722 }
723
724 @override
725 void dispatchOverscrollNotification(
726 ScrollMetrics metrics,
727 BuildContext context,
728 double overscroll,
729 ) {
730 OverscrollNotification(
731 metrics: metrics,
732 context: context,
733 overscroll: overscroll,
734 velocity: velocity,
735 ).dispatch(context);
736 }
737
738 @override
739 bool get shouldIgnorePointer => true;
740
741 @override
742 bool get isScrolling => true;
743
744 @override
745 double get velocity => _controller.velocity;
746
747 @override
748 void dispose() {
749 _completer.complete();
750 _controller.dispose();
751 super.dispose();
752 }
753
754 @override
755 String toString() {
756 return '${describeIdentity(this)}($_controller)';
757 }
758}
759

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com