haze 0.5.0 copy "haze: ^0.5.0" to clipboard
haze: ^0.5.0 copied to clipboard

Progressive (gradient) blur for Flutter: a true variable-radius Gaussian that fades from any edge, with an optional matching tint wash.

example/lib/main.dart

import 'dart:ui' as ui;

import 'package:flutter/cupertino.dart';
import 'package:haze/haze.dart';

import 'album.dart';

void main() => runApp(const HazeExampleApp());

const accent = Color(0xFFFA2B56);
const kNavBarHeight = 52.0;
const kMiniPlayerHeight = 64.0;
const kTabBarHeight = 54.0;

/// Room the band keeps for the fade, below the chrome it covers. The bar rides
/// the full-strength part, the ramp happens in the empty space under it.
const kFade = 64.0;
const hairline = Color(0x1FFFFFFF);

class HazeExampleApp extends StatelessWidget {
  const HazeExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const CupertinoApp(
      title: 'Haze',
      debugShowCheckedModeBanner: false,
      theme: CupertinoThemeData(
        brightness: Brightness.dark,
        primaryColor: accent,
        scaffoldBackgroundColor: Color(0xFF000000),
      ),
      home: AlbumPage(),
    );
  }
}

/// An album screen, close enough to the system one that the only thing left to
/// look at is the two bands: content passing under the bar at the top and the
/// player at the bottom, blurred harder the closer it gets to the edge.
class AlbumPage extends StatefulWidget {
  const AlbumPage({super.key});

  @override
  State<AlbumPage> createState() => _AlbumPageState();
}

class _AlbumPageState extends State<AlbumPage> {
  final _scroll = ScrollController();

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

  @override
  Widget build(BuildContext context) {
    final inset = MediaQuery.paddingOf(context);
    // The chrome, and the band that covers it plus its fade.
    final topChrome = inset.top + kNavBarHeight;
    final bottomChrome = inset.bottom + kMiniPlayerHeight + kTabBarHeight;

    return CupertinoPageScaffold(
      child: Stack(
        children: [
          const _Ambience(),
          CustomScrollView(
            controller: _scroll,
            slivers: [
              // The list runs the full height of the screen and slides under
              // both bands; the padding is only what keeps its two ends
              // reachable.
              SliverPadding(
                padding: EdgeInsets.only(top: topChrome),
                sliver: const SliverToBoxAdapter(child: _AlbumHeader()),
              ),
              SliverList.builder(
                itemCount: tracks.length,
                itemBuilder: (context, i) =>
                    _TrackRow(index: i, track: tracks[i]),
              ),
              SliverPadding(
                padding: EdgeInsets.only(bottom: bottomChrome + 28),
                sliver: const SliverToBoxAdapter(child: _AlbumFooter()),
              ),
            ],
          ),
          Positioned(
            top: 0,
            left: 0,
            right: 0,
            height: topChrome + kFade,
            child: Haze(
              edge: HazeEdge.top,
              sigma: 2,
              tint: CupertinoColors.black.withValues(alpha: .3),
              child: Align(
                alignment: Alignment.topCenter,
                child: _NavBar(scroll: _scroll, topInset: inset.top),
              ),
            ),
          ),
          Positioned(
            left: 0,
            right: 0,
            bottom: 0,
            height: bottomChrome + kFade,
            child: Haze(
              edge: HazeEdge.bottom,
              sigma: 3,
              tint: CupertinoColors.black.withValues(alpha: .3),
              child: Align(
                alignment: Alignment.bottomCenter,
                child: _PlayerDock(bottomInset: inset.bottom),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

/// The sleeve, blown up and blurred behind the top of the page — the wash the
/// system puts behind a now-playing screen, and something with real structure
/// for the top band to eat into.
class _Ambience extends StatelessWidget {
  const _Ambience();

  @override
  Widget build(BuildContext context) {
    return Positioned(
      top: 0,
      left: 0,
      right: 0,
      height: 480,
      child: ImageFiltered(
        imageFilter: ui.ImageFilter.blur(sigmaX: 48, sigmaY: 48),
        child: ShaderMask(
          blendMode: BlendMode.dstIn,
          shaderCallback: (rect) => const LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [Color(0xFFFFFFFF), Color(0x00FFFFFF)],
            stops: [0.4, 1],
          ).createShader(rect),
          child: const Opacity(opacity: 0.7, child: Sleeve()),
        ),
      ),
    );
  }
}

class _NavBar extends StatelessWidget {
  const _NavBar({required this.scroll, required this.topInset});

  final ScrollController scroll;
  final double topInset;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.only(top: topInset),
      child: SizedBox(
        height: kNavBarHeight,
        child: Row(
          children: [
            CupertinoButton(
              padding: const EdgeInsets.only(left: 6, right: 8),
              minimumSize: const Size(44, 44),
              onPressed: () {},
              child: const Row(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Icon(CupertinoIcons.back, size: 26, color: accent),
                  Text(
                    'Library',
                    style: TextStyle(
                      fontSize: 17,
                      letterSpacing: -0.4,
                      color: accent,
                    ),
                  ),
                ],
              ),
            ),
            Expanded(
              child: AnimatedBuilder(
                animation: scroll,
                builder: (context, _) {
                  // The title trades places with the sleeve: it fades in over
                  // the stretch where the artwork leaves under the bar.
                  final t = scroll.hasClients
                      ? ((scroll.offset - 200) / 60).clamp(0.0, 1.0)
                      : 0.0;
                  return Opacity(
                    opacity: t,
                    child: Transform.translate(
                      offset: Offset(0, (1 - t) * 10),
                      child: const Column(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          Text(
                            albumTitle,
                            maxLines: 1,
                            overflow: TextOverflow.ellipsis,
                            style: TextStyle(
                              fontSize: 15,
                              fontWeight: FontWeight.w600,
                              letterSpacing: -0.3,
                              color: CupertinoColors.white,
                            ),
                          ),
                          Text(
                            albumArtist,
                            maxLines: 1,
                            style: TextStyle(
                              fontSize: 11,
                              letterSpacing: -0.1,
                              color: Color(0x99FFFFFF),
                            ),
                          ),
                        ],
                      ),
                    ),
                  );
                },
              ),
            ),
            const _GlassButton(CupertinoIcons.shuffle),
            const SizedBox(width: 8),
            const _GlassButton(CupertinoIcons.ellipsis),
            const SizedBox(width: 12),
          ],
        ),
      ),
    );
  }
}

/// The round translucent control the system puts in a bar over content.
class _GlassButton extends StatelessWidget {
  const _GlassButton(this.icon);

  final IconData icon;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {},
      child: Container(
        width: 30,
        height: 30,
        alignment: Alignment.center,
        decoration: const BoxDecoration(
          shape: BoxShape.circle,
          color: Color(0x24FFFFFF),
        ),
        child: Icon(icon, size: 15, color: CupertinoColors.white),
      ),
    );
  }
}

class _AlbumHeader extends StatelessWidget {
  const _AlbumHeader();

  @override
  Widget build(BuildContext context) {
    final side = (MediaQuery.sizeOf(context).width * 0.62).clamp(180.0, 290.0);
    return Padding(
      padding: const EdgeInsets.fromLTRB(20, 14, 20, 0),
      child: Column(
        children: [
          Container(
            width: side,
            height: side,
            clipBehavior: Clip.antiAlias,
            decoration: BoxDecoration(
              borderRadius: BorderRadius.circular(10),
              boxShadow: const [
                BoxShadow(
                  color: Color(0xA6000000),
                  blurRadius: 34,
                  offset: Offset(0, 16),
                ),
              ],
            ),
            foregroundDecoration: BoxDecoration(
              borderRadius: BorderRadius.circular(10),
              border: Border.all(color: const Color(0x1AFFFFFF)),
            ),
            child: const Sleeve(),
          ),
          const SizedBox(height: 18),
          const Text(
            albumTitle,
            textAlign: TextAlign.center,
            style: TextStyle(
              fontSize: 22,
              fontWeight: FontWeight.w600,
              letterSpacing: -0.6,
              color: CupertinoColors.white,
            ),
          ),
          const SizedBox(height: 1),
          const Text(
            albumArtist,
            textAlign: TextAlign.center,
            style: TextStyle(fontSize: 22, letterSpacing: -0.6, color: accent),
          ),
          const SizedBox(height: 8),
          const Text(
            albumMeta,
            style: TextStyle(
              fontSize: 12,
              fontWeight: FontWeight.w600,
              letterSpacing: 0.4,
              color: Color(0x8CFFFFFF),
            ),
          ),
          const SizedBox(height: 16),
          const Row(
            children: [
              Expanded(child: _BigButton(CupertinoIcons.play_fill, 'Play')),
              SizedBox(width: 12),
              Expanded(child: _BigButton(CupertinoIcons.shuffle, 'Shuffle')),
            ],
          ),
          const SizedBox(height: 6),
        ],
      ),
    );
  }
}

class _BigButton extends StatelessWidget {
  const _BigButton(this.icon, this.label);

  final IconData icon;
  final String label;

  @override
  Widget build(BuildContext context) {
    return CupertinoButton(
      padding: EdgeInsets.zero,
      borderRadius: BorderRadius.circular(12),
      color: const Color(0x1FFFFFFF),
      onPressed: () {},
      child: SizedBox(
        height: 48,
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(icon, size: 18, color: accent),
            const SizedBox(width: 8),
            Text(
              label,
              style: const TextStyle(
                fontSize: 17,
                fontWeight: FontWeight.w600,
                letterSpacing: -0.3,
                color: accent,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _TrackRow extends StatelessWidget {
  const _TrackRow({required this.index, required this.track});

  final int index;
  final Track track;

  /// The track the mini player is on gets the system's playing state: accent
  /// number, and the little level meter instead of a digit.
  bool get playing => track.title == 'Fog Bank';

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(left: 20),
      child: Container(
        height: 52,
        padding: const EdgeInsets.only(right: 20),
        decoration: const BoxDecoration(
          border: Border(top: BorderSide(color: hairline, width: 0.5)),
        ),
        child: Row(
          children: [
            SizedBox(
              width: 28,
              child: playing
                  ? const Icon(
                      CupertinoIcons.speaker_2_fill,
                      size: 14,
                      color: accent,
                    )
                  : Text(
                      '${index + 1}',
                      style: const TextStyle(
                        fontSize: 16,
                        color: Color(0x80FFFFFF),
                        fontFeatures: [FontFeature.tabularFigures()],
                      ),
                    ),
            ),
            Expanded(
              child: Row(
                children: [
                  Flexible(
                    child: Text(
                      track.title,
                      maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                      style: TextStyle(
                        fontSize: 16,
                        letterSpacing: -0.3,
                        color: playing ? accent : CupertinoColors.white,
                      ),
                    ),
                  ),
                  if (track.explicit) ...[
                    const SizedBox(width: 6),
                    const _ExplicitBadge(),
                  ],
                ],
              ),
            ),
            Text(
              track.duration,
              style: const TextStyle(
                fontSize: 15,
                color: Color(0x66FFFFFF),
                fontFeatures: [FontFeature.tabularFigures()],
              ),
            ),
            const SizedBox(width: 14),
            const Icon(
              CupertinoIcons.ellipsis,
              size: 17,
              color: Color(0x66FFFFFF),
            ),
          ],
        ),
      ),
    );
  }
}

class _ExplicitBadge extends StatelessWidget {
  const _ExplicitBadge();

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 14,
      height: 14,
      alignment: Alignment.center,
      decoration: BoxDecoration(
        color: const Color(0x59FFFFFF),
        borderRadius: BorderRadius.circular(3),
      ),
      child: const Text(
        'E',
        style: TextStyle(
          fontSize: 9,
          fontWeight: FontWeight.w700,
          height: 1.1,
          color: Color(0xFF1C1C1E),
        ),
      ),
    );
  }
}

class _AlbumFooter extends StatelessWidget {
  const _AlbumFooter();

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Padding(
          padding: EdgeInsets.fromLTRB(20, 18, 20, 0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                '12 songs · 57 minutes',
                style: TextStyle(
                  fontSize: 13,
                  fontWeight: FontWeight.w600,
                  color: Color(0x99FFFFFF),
                ),
              ),
              SizedBox(height: 8),
              Text(
                albumNote,
                style: TextStyle(
                  fontSize: 13,
                  height: 1.45,
                  color: Color(0x73FFFFFF),
                ),
              ),
              SizedBox(height: 8),
              Text(
                albumRelease,
                style: TextStyle(fontSize: 12, color: Color(0x59FFFFFF)),
              ),
            ],
          ),
        ),
        const SizedBox(height: 26),
        const Padding(
          padding: EdgeInsets.symmetric(horizontal: 20),
          child: Text(
            'More by $albumArtist',
            style: TextStyle(
              fontSize: 21,
              fontWeight: FontWeight.w700,
              letterSpacing: -0.5,
              color: CupertinoColors.white,
            ),
          ),
        ),
        const SizedBox(height: 12),
        SizedBox(
          height: 190,
          child: ListView.separated(
            scrollDirection: Axis.horizontal,
            padding: const EdgeInsets.symmetric(horizontal: 20),
            itemCount: related.length,
            separatorBuilder: (_, _) => const SizedBox(width: 14),
            itemBuilder: (context, i) {
              final (title, artist, colors) = related[i];
              return SizedBox(
                width: 140,
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    ClipRRect(
                      borderRadius: BorderRadius.circular(8),
                      child: SizedBox(
                        width: 140,
                        height: 140,
                        child: Sleeve(colors: colors),
                      ),
                    ),
                    const SizedBox(height: 7),
                    Text(
                      title,
                      maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                      style: const TextStyle(
                        fontSize: 13,
                        letterSpacing: -0.2,
                        color: CupertinoColors.white,
                      ),
                    ),
                    Text(
                      artist,
                      maxLines: 1,
                      style: const TextStyle(
                        fontSize: 13,
                        color: Color(0x8CFFFFFF),
                      ),
                    ),
                  ],
                ),
              );
            },
          ),
        ),
      ],
    );
  }
}

/// Mini player and tab bar. Both live inside the bottom band, so the list
/// scrolls up into them and dissolves rather than stopping on a line.
class _PlayerDock extends StatelessWidget {
  const _PlayerDock({required this.bottomInset});

  final double bottomInset;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Container(
          height: kMiniPlayerHeight,
          decoration: const BoxDecoration(
            // The hairline the system keeps even over a blur — it is what
            // says the dock is a surface and not part of the page.
            border: Border(
              top: BorderSide(color: Color(0x26FFFFFF), width: 0.5),
            ),
          ),
          child: Row(
            children: [
              const SizedBox(width: 12),
              const ClipRRect(
                borderRadius: BorderRadius.all(Radius.circular(6)),
                child: SizedBox(width: 44, height: 44, child: Sleeve()),
              ),
              const SizedBox(width: 12),
              const Expanded(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      'Fog Bank',
                      maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                      style: TextStyle(
                        fontSize: 15,
                        letterSpacing: -0.2,
                        color: CupertinoColors.white,
                      ),
                    ),
                    Text(
                      albumArtist,
                      maxLines: 1,
                      style: TextStyle(fontSize: 13, color: Color(0x8CFFFFFF)),
                    ),
                  ],
                ),
              ),
              const _DockButton(CupertinoIcons.pause_fill),
              const _DockButton(CupertinoIcons.forward_fill),
              const SizedBox(width: 10),
            ],
          ),
        ),
        SizedBox(
          height: kTabBarHeight,
          child: Row(
            children: const [
              _Tab(CupertinoIcons.house_fill, 'Home'),
              _Tab(CupertinoIcons.square_grid_2x2_fill, 'New'),
              _Tab(CupertinoIcons.dot_radiowaves_left_right, 'Radio'),
              _Tab(CupertinoIcons.music_albums_fill, 'Library', active: true),
              _Tab(CupertinoIcons.search, 'Search'),
            ],
          ),
        ),
        SizedBox(height: bottomInset),
      ],
    );
  }
}

class _DockButton extends StatelessWidget {
  const _DockButton(this.icon);

  final IconData icon;

  @override
  Widget build(BuildContext context) {
    return CupertinoButton(
      padding: EdgeInsets.zero,
      minimumSize: const Size(44, 44),
      onPressed: () {},
      child: Icon(icon, size: 24, color: CupertinoColors.white),
    );
  }
}

class _Tab extends StatelessWidget {
  const _Tab(this.icon, this.label, {this.active = false});

  final IconData icon;
  final String label;
  final bool active;

  @override
  Widget build(BuildContext context) {
    final color = active ? accent : const Color(0x8CFFFFFF);
    return Expanded(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(icon, size: 23, color: color),
          const SizedBox(height: 3),
          Text(
            label,
            style: TextStyle(
              fontSize: 10,
              fontWeight: FontWeight.w500,
              color: color,
            ),
          ),
        ],
      ),
    );
  }
}
1
likes
160
points
190
downloads

Documentation

API reference

Publisher

verified publisherherme.se

Weekly Downloads

Progressive (gradient) blur for Flutter: a true variable-radius Gaussian that fades from any edge, with an optional matching tint wash.

Homepage
Repository (GitHub)
View/report issues

License

BSD-3-Clause (license)

Dependencies

flutter

More

Packages that depend on haze