// Copyright 2019 The Flutter team. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:gallery/feature_discovery/animation.dart'; import 'package:gallery/feature_discovery/overlay.dart'; /// [Widget] to enforce a global lock system for [FeatureDiscovery] widgets. /// /// This widget enforces that at most one [FeatureDiscovery] widget in its /// widget tree is shown at a time. /// /// Users wanting to use [FeatureDiscovery] need to put this controller /// above [FeatureDiscovery] widgets in the widget tree. class FeatureDiscoveryController extends StatefulWidget { final Widget child; FeatureDiscoveryController(this.child); static _FeatureDiscoveryControllerState of(BuildContext context) { final matchResult = context.findAncestorStateOfType<_FeatureDiscoveryControllerState>(); if (matchResult != null) { return matchResult; } throw FlutterError( 'FeatureDiscoveryController.of() called with a context that does not ' 'contain a FeatureDiscoveryController.\n The context used was:\n ' '$context'); } @override _FeatureDiscoveryControllerState createState() => _FeatureDiscoveryControllerState(); } class _FeatureDiscoveryControllerState extends State { bool _isLocked = false; /// Flag to indicate whether a [FeatureDiscovery] widget descendant is /// currently showing its overlay or not. /// /// If true, then no other [FeatureDiscovery] widget should display its /// overlay. bool get isLocked => _isLocked; /// Lock the controller. /// /// Note we do not [setState] here because this function will be called /// by the first [FeatureDiscovery] ready to show its overlay, and any /// additional [FeatureDiscovery] widgets wanting to show their overlays /// will already be scheduled to be built, so the lock change will be caught /// in their builds. void lock() => _isLocked = true; /// Unlock the controller. void unlock() => setState(() => _isLocked = false); @override void didChangeDependencies() { super.didChangeDependencies(); assert( context.findAncestorStateOfType<_FeatureDiscoveryControllerState>() == null, 'There should not be another ancestor of type ' 'FeatureDiscoveryController in the widget tree.', ); } @override Widget build(BuildContext context) => widget.child; } /// Widget that highlights the [child] with an overlay. /// /// This widget loosely follows the guidelines set forth in the Material Specs: /// https://material.io/archive/guidelines/growth-communications/feature-discovery.html. class FeatureDiscovery extends StatefulWidget { /// Title to be displayed in the overlay. final String title; /// Description to be displayed in the overlay. final String description; /// Icon to be promoted. final Icon child; /// Flag to indicate whether to show the overlay or not anchored to the /// [child]. final bool showOverlay; /// Callback invoked when the user dismisses an overlay. final void Function() onDismiss; /// Callback invoked when the user taps on the tap target of an overlay. final void Function() onTap; /// Color with which to fill the outer circle. final Color color; @visibleForTesting static final overlayKey = Key('overlay key'); @visibleForTesting static final gestureDetectorKey = Key('gesture detector key'); FeatureDiscovery({ @required this.title, @required this.description, @required this.child, @required this.showOverlay, this.onDismiss, this.onTap, this.color, }) { assert(title != null); assert(description != null); assert(child != null); assert(showOverlay != null); } @override _FeatureDiscoveryState createState() => _FeatureDiscoveryState(); } class _FeatureDiscoveryState extends State with TickerProviderStateMixin { bool showOverlay = false; FeatureDiscoveryStatus status = FeatureDiscoveryStatus.closed; AnimationController openController; AnimationController rippleController; AnimationController tapController; AnimationController dismissController; Animations animations; OverlayEntry overlay; Widget buildOverlay(BuildContext ctx, Offset center) { debugCheckHasMediaQuery(ctx); debugCheckHasDirectionality(ctx); final deviceSize = MediaQuery.of(ctx).size; final color = widget.color ?? Theme.of(ctx).primaryColor; // Wrap in transparent [Material] to enable widgets that require one. return Material( key: FeatureDiscovery.overlayKey, type: MaterialType.transparency, child: Stack( children: [ GestureDetector( key: FeatureDiscovery.gestureDetectorKey, onTap: dismiss, child: Container( width: double.infinity, height: double.infinity, color: Colors.transparent, ), ), Background( animations: animations, status: status, color: color, center: center, deviceSize: deviceSize, textDirection: Directionality.of(ctx), ), Content( animations: animations, status: status, center: center, deviceSize: deviceSize, title: widget.title, description: widget.description, textTheme: Theme.of(ctx).textTheme, ), Ripple( animations: animations, status: status, center: center, ), TapTarget( animations: animations, status: status, center: center, child: widget.child, onTap: tap, ), ], ), ); } /// Method to handle user tap on [TapTarget]. /// /// Tapping will stop any active controller and start the [tapController]. void tap() { openController.stop(); rippleController.stop(); dismissController.stop(); tapController.forward(from: 0.0); } /// Method to handle user dismissal. /// /// Dismissal will stop any active controller and start the /// [dismissController]. void dismiss() { openController.stop(); rippleController.stop(); tapController.stop(); dismissController.forward(from: 0.0); } @override Widget build(BuildContext context) { return LayoutBuilder(builder: (ctx, _) { if (overlay != null) { SchedulerBinding.instance.addPostFrameCallback((_) { // [OverlayEntry] needs to be explicitly rebuilt when necessary. overlay.markNeedsBuild(); }); } else { if (showOverlay && !FeatureDiscoveryController.of(ctx).isLocked) { final entry = OverlayEntry( builder: (_) => buildOverlay(ctx, getOverlayCenter(ctx)), ); // Lock [FeatureDiscoveryController] early in order to prevent // another [FeatureDiscovery] widget from trying to show its // overlay while the post frame callback and set state are not // complete. FeatureDiscoveryController.of(ctx).lock(); SchedulerBinding.instance.addPostFrameCallback((_) { setState(() { overlay = entry; status = FeatureDiscoveryStatus.closed; openController.forward(from: 0.0); }); Overlay.of(context).insert(entry); }); } } return widget.child; }); } /// Compute the center position of the overlay. Offset getOverlayCenter(BuildContext parentCtx) { final box = parentCtx.findRenderObject() as RenderBox; final size = box.size; final topLeftPosition = box.localToGlobal(Offset.zero); final centerPosition = Offset( topLeftPosition.dx + size.width / 2, topLeftPosition.dy + size.height / 2, ); return centerPosition; } @override void initState() { super.initState(); initAnimationControllers(); initAnimations(); showOverlay = widget.showOverlay; } void initAnimationControllers() { openController = AnimationController( duration: const Duration(milliseconds: 500), vsync: this, ) ..addListener(() { setState(() {}); }) ..addStatusListener((animationStatus) { if (animationStatus == AnimationStatus.forward) { setState(() => status = FeatureDiscoveryStatus.open); } else if (animationStatus == AnimationStatus.completed) { rippleController.forward(from: 0.0); } }); rippleController = AnimationController( duration: const Duration(milliseconds: 1000), vsync: this, ) ..addListener(() { setState(() {}); }) ..addStatusListener((animationStatus) { if (animationStatus == AnimationStatus.forward) { setState(() => status = FeatureDiscoveryStatus.ripple); } else if (animationStatus == AnimationStatus.completed) { rippleController.forward(from: 0.0); } }); tapController = AnimationController( duration: const Duration(milliseconds: 250), vsync: this, ) ..addListener(() { setState(() {}); }) ..addStatusListener((animationStatus) { if (animationStatus == AnimationStatus.forward) { setState(() => status = FeatureDiscoveryStatus.tap); } else if (animationStatus == AnimationStatus.completed) { widget.onTap?.call(); cleanUponOverlayClose(); } }); dismissController = AnimationController( duration: const Duration(milliseconds: 250), vsync: this, ) ..addListener(() { setState(() {}); }) ..addStatusListener((animationStatus) { if (animationStatus == AnimationStatus.forward) { setState(() => status = FeatureDiscoveryStatus.dismiss); } else if (animationStatus == AnimationStatus.completed) { widget.onDismiss?.call(); cleanUponOverlayClose(); } }); } void initAnimations() { assert(openController != null); assert(rippleController != null); assert(tapController != null); assert(dismissController != null); animations = Animations( openController, tapController, rippleController, dismissController, ); } /// Clean up once overlay has been dismissed or tap target has been tapped. /// /// This is called upon [tapController] and [dismissController] end. void cleanUponOverlayClose() { FeatureDiscoveryController.of(context).unlock(); setState(() { status = FeatureDiscoveryStatus.closed; showOverlay = false; overlay?.remove(); overlay = null; }); } @override void didUpdateWidget(FeatureDiscovery oldWidget) { super.didUpdateWidget(oldWidget); if (widget.showOverlay != oldWidget.showOverlay) { showOverlay = widget.showOverlay; } } @override void dispose() { overlay?.remove(); openController?.dispose(); rippleController?.dispose(); tapController?.dispose(); dismissController?.dispose(); super.dispose(); } }