backdrop_demo.dart 11.6 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6 7 8 9 10 11 12 13 14
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:math' as math;

import 'package:flutter/material.dart';

// This demo displays one Category at a time. The backdrop show a list
// of all of the categories and the selected category is displayed
// (CategoryView) on top of the backdrop.

class Category {
  const Category({ this.title, this.assets });
15 16
  final String? title;
  final List<String>? assets;
17 18
  @override
  String toString() => '$runtimeType("$title")';
19 20
}

21 22
const List<Category> allCategories = <Category>[
  Category(
23
    title: 'Accessories',
24
    assets: <String>[
25 26 27 28 29 30
      'products/belt.png',
      'products/earrings.png',
      'products/backpack.png',
      'products/hat.png',
      'products/scarf.png',
      'products/sunnies.png',
31 32
    ],
  ),
33
  Category(
34
    title: 'Blue',
35
    assets: <String>[
36 37 38 39
      'products/backpack.png',
      'products/cup.png',
      'products/napkins.png',
      'products/top.png',
40 41
    ],
  ),
42
  Category(
43
    title: 'Cold Weather',
44
    assets: <String>[
45 46 47 48 49
      'products/jacket.png',
      'products/jumper.png',
      'products/scarf.png',
      'products/sweater.png',
      'products/sweats.png',
50 51
    ],
  ),
52
  Category(
53
    title: 'Home',
54
    assets: <String>[
55 56 57 58 59
      'products/cup.png',
      'products/napkins.png',
      'products/planters.png',
      'products/table.png',
      'products/teaset.png',
60 61
    ],
  ),
62
  Category(
63
    title: 'Tops',
64
    assets: <String>[
65 66 67 68
      'products/jumper.png',
      'products/shirt.png',
      'products/sweater.png',
      'products/top.png',
69 70
    ],
  ),
71
  Category(
72
    title: 'Everything',
73
    assets: <String>[
74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92
      'products/backpack.png',
      'products/belt.png',
      'products/cup.png',
      'products/dress.png',
      'products/earrings.png',
      'products/flatwear.png',
      'products/hat.png',
      'products/jacket.png',
      'products/jumper.png',
      'products/napkins.png',
      'products/planters.png',
      'products/scarf.png',
      'products/shirt.png',
      'products/sunnies.png',
      'products/sweater.png',
      'products/sweats.png',
      'products/table.png',
      'products/teaset.png',
      'products/top.png',
93 94 95 96 97
    ],
  ),
];

class CategoryView extends StatelessWidget {
98
  const CategoryView({ super.key, this.category });
99

100
  final Category? category;
101 102 103 104

  @override
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);
105 106
    return Scrollbar(
      child: ListView(
107
        primary: true,
108
        key: PageStorageKey<Category?>(category),
109 110 111 112
        padding: const EdgeInsets.symmetric(
          vertical: 16.0,
          horizontal: 64.0,
        ),
113
        children: category!.assets!.map<Widget>((String asset) {
114 115 116 117 118 119 120 121 122 123
          return Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              Card(
                child: Container(
                  width: 144.0,
                  alignment: Alignment.center,
                  child: Column(
                    children: <Widget>[
                      Image.asset(
124
                        asset,
125 126 127 128 129 130 131 132 133 134
                        package: 'flutter_gallery_assets',
                        fit: BoxFit.contain,
                      ),
                      Container(
                        padding: const EdgeInsets.only(bottom: 16.0),
                        alignment: AlignmentDirectional.center,
                        child: Text(
                          asset,
                          style: theme.textTheme.caption,
                        ),
135
                      ),
136 137
                    ],
                  ),
138 139
                ),
              ),
140 141 142 143
              const SizedBox(height: 24.0),
            ],
          );
        }).toList(),
144
      ),
145 146 147 148 149
    );
  }
}

// One BackdropPanel is visible at a time. It's stacked on top of the
Pierre-Louis's avatar
Pierre-Louis committed
150
// BackdropDemo.
151 152
class BackdropPanel extends StatelessWidget {
  const BackdropPanel({
153
    super.key,
154 155 156 157 158
    this.onTap,
    this.onVerticalDragUpdate,
    this.onVerticalDragEnd,
    this.title,
    this.child,
159
  });
160

161 162 163 164 165
  final VoidCallback? onTap;
  final GestureDragUpdateCallback? onVerticalDragUpdate;
  final GestureDragEndCallback? onVerticalDragEnd;
  final Widget? title;
  final Widget? child;
166 167 168 169

  @override
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);
170
    return Material(
171 172
      elevation: 2.0,
      borderRadius: const BorderRadius.only(
173 174
        topLeft: Radius.circular(16.0),
        topRight: Radius.circular(16.0),
175
      ),
176
      child: Column(
177 178
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
179
          GestureDetector(
180 181 182 183
            behavior: HitTestBehavior.opaque,
            onVerticalDragUpdate: onVerticalDragUpdate,
            onVerticalDragEnd: onVerticalDragEnd,
            onTap: onTap,
184
            child: Container(
185 186 187
              height: 48.0,
              padding: const EdgeInsetsDirectional.only(start: 16.0),
              alignment: AlignmentDirectional.centerStart,
188
              child: DefaultTextStyle(
189
                style: theme.textTheme.subtitle1!,
190
                child: Tooltip(
191 192 193
                  message: 'Tap to dismiss',
                  child: title,
                ),
194 195 196 197
              ),
            ),
          ),
          const Divider(height: 1.0),
198
          Expanded(child: child!),
199 200 201 202 203 204 205 206 207
        ],
      ),
    );
  }
}

// Cross fades between 'Select a Category' and 'Asset Viewer'.
class BackdropTitle extends AnimatedWidget {
  const BackdropTitle({
208 209 210
    super.key,
    required Animation<double> super.listenable,
  });
211 212 213

  @override
  Widget build(BuildContext context) {
214
    final Animation<double> animation = listenable as Animation<double>;
215
    return DefaultTextStyle(
216
      style: Theme.of(context).primaryTextTheme.headline6!,
217 218
      softWrap: false,
      overflow: TextOverflow.ellipsis,
219
      child: Stack(
220
        children: <Widget>[
221 222 223
          Opacity(
            opacity: CurvedAnimation(
              parent: ReverseAnimation(animation),
224 225 226 227
              curve: const Interval(0.5, 1.0),
            ).value,
            child: const Text('Select a Category'),
          ),
228 229
          Opacity(
            opacity: CurvedAnimation(
230 231 232 233 234 235 236 237 238 239 240 241 242
              parent: animation,
              curve: const Interval(0.5, 1.0),
            ).value,
            child: const Text('Asset Viewer'),
          ),
        ],
      ),
    );
  }
}

// This widget is essentially the backdrop itself.
class BackdropDemo extends StatefulWidget {
243
  const BackdropDemo({super.key});
244

245 246 247
  static const String routeName = '/material/backdrop';

  @override
248
  State<BackdropDemo> createState() => _BackdropDemoState();
249 250 251
}

class _BackdropDemoState extends State<BackdropDemo> with SingleTickerProviderStateMixin {
252
  final GlobalKey _backdropKey = GlobalKey(debugLabel: 'Backdrop');
253
  late AnimationController _controller;
254 255 256 257 258
  Category _category = allCategories[0];

  @override
  void initState() {
    super.initState();
259
    _controller = AnimationController(
260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288
      duration: const Duration(milliseconds: 300),
      value: 1.0,
      vsync: this,
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _changeCategory(Category category) {
    setState(() {
      _category = category;
      _controller.fling(velocity: 2.0);
    });
  }

  bool get _backdropPanelVisible {
    final AnimationStatus status = _controller.status;
    return status == AnimationStatus.completed || status == AnimationStatus.forward;
  }

  void _toggleBackdropPanelVisibility() {
    _controller.fling(velocity: _backdropPanelVisible ? -2.0 : 2.0);
  }

  double get _backdropHeight {
289
    final RenderBox renderBox = _backdropKey.currentContext!.findRenderObject()! as RenderBox;
290 291 292 293 294 295 296 297 298 299
    return renderBox.size.height;
  }

  // By design: the panel can only be opened with a swipe. To close the panel
  // the user must either tap its heading or the backdrop's menu icon.

  void _handleDragUpdate(DragUpdateDetails details) {
    if (_controller.isAnimating || _controller.status == AnimationStatus.completed)
      return;

300
    _controller.value -= details.primaryDelta! / _backdropHeight;
301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325
  }

  void _handleDragEnd(DragEndDetails details) {
    if (_controller.isAnimating || _controller.status == AnimationStatus.completed)
      return;

    final double flingVelocity = details.velocity.pixelsPerSecond.dy / _backdropHeight;
    if (flingVelocity < 0.0)
      _controller.fling(velocity: math.max(2.0, -flingVelocity));
    else if (flingVelocity > 0.0)
      _controller.fling(velocity: math.min(-2.0, -flingVelocity));
    else
      _controller.fling(velocity: _controller.value < 0.5 ? -2.0 : 2.0);
  }

  // Stacks a BackdropPanel, which displays the selected category, on top
  // of the backdrop. The categories are displayed with ListTiles. Just one
  // can be selected at a time. This is a LayoutWidgetBuild function because
  // we need to know how big the BackdropPanel will be to set up its
  // animation.
  Widget _buildStack(BuildContext context, BoxConstraints constraints) {
    const double panelTitleHeight = 48.0;
    final Size panelSize = constraints.biggest;
    final double panelTop = panelSize.height - panelTitleHeight;

326 327 328 329 330 331 332 333
    final Animation<RelativeRect> panelAnimation = _controller.drive(
      RelativeRectTween(
        begin: RelativeRect.fromLTRB(
          0.0,
          panelTop - MediaQuery.of(context).padding.bottom,
          0.0,
          panelTop - panelSize.height,
        ),
334
        end: RelativeRect.fill,
335 336 337 338 339 340
      ),
    );

    final ThemeData theme = Theme.of(context);
    final List<Widget> backdropItems = allCategories.map<Widget>((Category category) {
      final bool selected = category == _category;
341
      return Material(
342
        shape: const RoundedRectangleBorder(
343
          borderRadius: BorderRadius.all(Radius.circular(4.0)),
344 345 346 347
        ),
        color: selected
          ? Colors.white.withOpacity(0.25)
          : Colors.transparent,
348
        child: ListTile(
349
          title: Text(category.title!),
350 351 352 353 354 355
          selected: selected,
          onTap: () {
            _changeCategory(category);
          },
        ),
      );
356
    }).toList();
357

358
    return Container(
359 360
      key: _backdropKey,
      color: theme.primaryColor,
361
      child: Stack(
362
        children: <Widget>[
363
          ListTileTheme(
364
            iconColor: theme.primaryIconTheme.color,
365 366
            textColor: theme.primaryTextTheme.headline6!.color!.withOpacity(0.6),
            selectedColor: theme.primaryTextTheme.headline6!.color,
367
            child: Padding(
368
              padding: const EdgeInsets.symmetric(horizontal: 16.0),
369
              child: Column(
370 371 372 373 374
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: backdropItems,
              ),
            ),
          ),
375
          PositionedTransition(
376
            rect: panelAnimation,
377
            child: BackdropPanel(
378 379 380
              onTap: _toggleBackdropPanelVisibility,
              onVerticalDragUpdate: _handleDragUpdate,
              onVerticalDragEnd: _handleDragEnd,
381
              title: Text(_category.title!),
382
              child: CategoryView(category: _category),
383 384 385 386 387 388 389 390 391
            ),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
392 393
    return Scaffold(
      appBar: AppBar(
394
        elevation: 0.0,
395
        title: BackdropTitle(
396 397
          listenable: _controller.view,
        ),
398
        actions: <Widget>[
399
          IconButton(
400
            onPressed: _toggleBackdropPanelVisibility,
401
            icon: AnimatedIcon(
402
              icon: AnimatedIcons.close_menu,
403
              semanticLabel: 'close',
404 405 406 407
              progress: _controller.view,
            ),
          ),
        ],
408
      ),
409
      body: LayoutBuilder(
410 411 412 413 414
        builder: _buildStack,
      ),
    );
  }
}