hollow 0.1.4 copy "hollow: ^0.1.4" to clipboard
hollow: ^0.1.4 copied to clipboard

Skeleton loading screens for Flutter. Generates pixel-perfect shimmer placeholders from your real RenderObject tree.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:hollow/hollow.dart';

import 'bones/bones_registry.dart';

void main() {
  registerAllBones();
  runApp(const App());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        useMaterial3: true,
      ),
      home: const FeedScreen(),
    );
  }
}

class FeedScreen extends StatefulWidget {
  const FeedScreen({super.key});

  @override
  State<FeedScreen> createState() => _FeedScreenState();
}

class _FeedScreenState extends State<FeedScreen> {
  bool _loading = true;

  @override
  void initState() {
    super.initState();
    Future.delayed(const Duration(seconds: 5), () {
      if (mounted) setState(() => _loading = false);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: SingleChildScrollView(
          padding: const EdgeInsets.all(16),
          child: Column(
            children: [
            Skeleton(
              name: 'photo-card',
              loading: _loading,
              fixture: const PhotoCard(
                imageUrl:
                    'https://images.unsplash.com/photo-1706195782033-6a351ce67878?fm=jpg&q=60&w=3000&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
                title: 'Pixel-perfect skeletons in Flutter',
                subtitle: 'A deep dive into the RenderObject tree',
                author: 'r4yb3l',
                readTime: '4 min read',
                likes: 128,
              ),
              child: const PhotoCard(
                imageUrl:
                    'https://images.unsplash.com/photo-1706195782033-6a351ce67878?fm=jpg&q=60&w=3000&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
                title: 'Pixel-perfect skeletons in Flutter',
                subtitle: 'A deep dive into the RenderObject tree',
                author: 'r4yb3l',
                readTime: '4 min read',
                likes: 128,
              ),
            ),
            const SizedBox(height: 16),
            Skeleton(
              name: 'profile-card',
              loading: _loading,
              fixture: const ProfileCard(
                name: 'Gabriel Torres',
                handle: '@gabtorres',
                bio: 'Flutter developer & open source contributor',
                followers: '2.4k',
                following: '312',
              ),
              child: const ProfileCard(
                name: 'Gabriel Torres',
                handle: '@gabtorres',
                bio: 'Flutter developer & open source contributor',
                followers: '2.4k',
                following: '312',
              ),
            ),
            const SizedBox(height: 16),
            Skeleton(
              name: 'stat-card',
              loading: _loading,
              fixture: const StatCard(
                title: 'Monthly Revenue',
                value: '\$12,847',
                change: '+12.5%',
                icon: Icons.trending_up,
              ),
              child: const StatCard(
                title: 'Monthly Revenue',
                value: '\$12,847',
                change: '+12.5%',
                icon: Icons.trending_up,
              ),
            ),
            const SizedBox(height: 16),
            Skeleton(
              name: 'list-tile',
              loading: _loading,
              fixture: const MessageTile(
                name: 'Laura Mendez',
                message: 'Hey! Check out the new skeleton package',
                time: '2m ago',
                avatarUrl: 'https://img.freepik.com/free-photo/young-beautiful-woman-pink-warm-sweater-natural-look-smiling-portrait-isolated-long-hair_285396-896.jpg?semt=ais_incoming&w=740&q=80',
              ),
              child: const MessageTile(
                name: 'Laura Mendez',
                message: 'Hey! Check out the new skeleton package',
                time: '2m ago',
                avatarUrl: 'https://img.freepik.com/free-photo/young-beautiful-woman-pink-warm-sweater-natural-look-smiling-portrait-isolated-long-hair_285396-896.jpg?semt=ais_incoming&w=740&q=80',
              ),
            ),
            const SizedBox(height: 16),
            Skeleton(
              name: 'list-tile',
              loading: _loading,
              fixture: const MessageTile(
                name: 'Carlos Ruiz',
                message: 'The shimmer animation looks smooth!',
                time: '15m ago',
                avatarUrl: 'https://static.vecteezy.com/system/resources/thumbnails/005/346/410/small/close-up-portrait-of-smiling-handsome-young-caucasian-man-face-looking-at-camera-on-isolated-light-gray-studio-background-photo.jpg',
              ),
              child: const MessageTile(
                name: 'Carlos Ruiz',
                message: 'The shimmer animation looks smooth!',
                time: '15m ago',
                avatarUrl: 'https://static.vecteezy.com/system/resources/thumbnails/005/346/410/small/close-up-portrait-of-smiling-handsome-young-caucasian-man-face-looking-at-camera-on-isolated-light-gray-studio-background-photo.jpg',
              ),
            ),
          ],
              ),
            ),
      ),
        );
      }
    }

class PhotoCard extends StatelessWidget {
  const PhotoCard({
    super.key,
    required this.imageUrl,
    required this.title,
    required this.subtitle,
    required this.author,
    required this.readTime,
    required this.likes,
  });

  final String imageUrl;
  final String title;
  final String subtitle;
  final String author;
  final String readTime;
  final int likes;

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;

    return Card(
      elevation: 0,
      clipBehavior: Clip.antiAlias,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
        side: BorderSide(color: colors.outlineVariant),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Image.network(
            imageUrl,
            height: 200,
            width: double.infinity,
            fit: BoxFit.cover,
            errorBuilder: (_, __, ___) => Container(
              height: 200,
              color: colors.surfaceContainerHighest,
              child: const Icon(Icons.image, size: 48),
            ),
          ),
          Padding(
            padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  title,
                  style: Theme.of(context).textTheme.titleLarge?.copyWith(
                        fontWeight: FontWeight.bold,
                      ),
                ),
                const SizedBox(height: 6),
                Text(
                  subtitle,
                  style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                        color: colors.onSurfaceVariant,
                      ),
                  maxLines: 2,
                  overflow: TextOverflow.ellipsis,
                ),
              ],
            ),
          ),
          Padding(
            padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
            child: Row(
              children: [
                const CircleAvatar(
                  radius: 16,
                  backgroundImage: NetworkImage(
                    'https://avatars.githubusercontent.com/u/108087778?v=4',
                  ),
                ),
                const SizedBox(width: 10),
                Expanded(
                  child: Text(
                    author,
                    style: Theme.of(context).textTheme.labelLarge,
                  ),
                ),
                Icon(Icons.favorite_border, size: 18, color: colors.outline),
                const SizedBox(width: 4),
                Text(
                  '$likes',
                  style: Theme.of(context).textTheme.labelSmall,
                ),
                const SizedBox(width: 12),
                Icon(Icons.schedule, size: 16, color: colors.outline),
                const SizedBox(width: 4),
                Text(
                  readTime,
                  style: Theme.of(context).textTheme.labelSmall?.copyWith(
                        color: colors.onSurfaceVariant,
                      ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class ProfileCard extends StatefulWidget {
  const ProfileCard({
    super.key,
    required this.name,
    required this.handle,
    required this.bio,
    required this.followers,
    required this.following,
  });

  final String name;
  final String handle;
  final String bio;
  final String followers;
  final String following;

  @override
  State<ProfileCard> createState() => _ProfileCardState();
}

class _ProfileCardState extends State<ProfileCard> {
  final _controller = ExpansibleController();
  bool _expanded = false;

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;

    return Card(
      elevation: 0,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
        side: BorderSide(color: colors.outlineVariant),
      ),
      clipBehavior: Clip.antiAlias,
      child: ExpansionTile(
        controller: _controller,
        initiallyExpanded: false,
        onExpansionChanged: (v) => setState(() => _expanded = v),
        tilePadding: const EdgeInsets.all(20),
        childrenPadding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
        leading: const CircleAvatar(
          radius: 28,
          backgroundImage: NetworkImage(
            'https://st5.depositphotos.com/88987118/74071/v/450/depositphotos_740719558-stock-illustration-man-professional-business-casual-young.jpg',
          ),
        ),
        title: Text(
          widget.name,
          style: Theme.of(context).textTheme.titleMedium?.copyWith(
                fontWeight: FontWeight.bold,
              ),
        ),
        subtitle: Text(
          widget.handle,
          style: Theme.of(context).textTheme.bodySmall?.copyWith(
                color: colors.primary,
              ),
        ),
        trailing: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            FilledButton.tonal(
              onPressed: () {},
              child: const Text('Follow'),
            ),
            const SizedBox(width: 8),
            GestureDetector(
              onTap: () {
                _expanded ? _controller.collapse() : _controller.expand();
              },
              child: CircleAvatar(
                radius: 14,
                backgroundColor: colors.surfaceContainerHighest,
                child: Icon(
                  _expanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
                  size: 18,
                  color: colors.onSurfaceVariant,
                ),
              ),
            ),
          ],
        ),
        children: [
          Align(
            alignment: Alignment.centerLeft,
            child: Text(
              widget.bio,
              style: Theme.of(context).textTheme.bodyMedium,
            ),
          ),
          const SizedBox(height: 16),
          const Divider(),
          const SizedBox(height: 12),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              _Stat(label: 'Followers', value: widget.followers),
              _Stat(label: 'Following', value: widget.following),
            ],
          ),
        ],
      ),
    );
  }
}

class _Stat extends StatelessWidget {
  const _Stat({required this.label, required this.value});

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text(
          value,
          style: Theme.of(context).textTheme.titleLarge?.copyWith(
                fontWeight: FontWeight.bold,
              ),
        ),
        const SizedBox(height: 2),
        Text(
          label,
          style: Theme.of(context).textTheme.bodySmall?.copyWith(
                color: Theme.of(context).colorScheme.onSurfaceVariant,
              ),
        ),
      ],
    );
  }
}

class StatCard extends StatelessWidget {
  const StatCard({
    super.key,
    required this.title,
    required this.value,
    required this.change,
    required this.icon,
  });

  final String title;
  final String value;
  final String change;
  final IconData icon;

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;

    return Card(
      elevation: 0,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
        side: BorderSide(color: colors.outlineVariant),
      ),
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Row(
          children: [
            Container(
              width: 52,
              height: 52,
              decoration: BoxDecoration(
                color: colors.primaryContainer,
                borderRadius: BorderRadius.circular(14),
              ),
              child: Icon(icon, color: colors.primary, size: 26),
            ),
            const SizedBox(width: 16),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    title,
                    style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                          color: colors.onSurfaceVariant,
                        ),
                  ),
                  const SizedBox(height: 4),
                  Text(
                    value,
                    style: Theme.of(context).textTheme.headlineSmall?.copyWith(
                          fontWeight: FontWeight.bold,
                        ),
                  ),
                ],
              ),
            ),
            Container(
              padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
              decoration: BoxDecoration(
                color: Colors.green.withAlpha(25),
                borderRadius: BorderRadius.circular(20),
              ),
              child: Text(
                change,
                style: Theme.of(context).textTheme.labelMedium?.copyWith(
                      color: Colors.green,
                      fontWeight: FontWeight.bold,
                    ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class MessageTile extends StatelessWidget {
  const MessageTile({
    super.key,
    required this.name,
    required this.message,
    required this.time,
    this.avatarUrl,
  });

  final String name;
  final String message;
  final String time;
  final String? avatarUrl;

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;

    return Card(
      elevation: 0,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(12),
        side: BorderSide(color: colors.outlineVariant),
      ),
      child: Padding(
        padding: const EdgeInsets.all(14),
        child: Row(
          children: [
            CircleAvatar(
              radius: 22,
              backgroundImage: avatarUrl != null
                  ? NetworkImage(avatarUrl!)
                  : null,
            ),
            const SizedBox(width: 12),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Row(
                    children: [
                      Text(
                        name,
                        style: Theme.of(context).textTheme.titleSmall?.copyWith(
                              fontWeight: FontWeight.bold,
                            ),
                      ),
                      const Spacer(),
                      Text(
                        time,
                        style: Theme.of(context).textTheme.labelSmall?.copyWith(
                              color: colors.onSurfaceVariant,
                            ),
                      ),
                    ],
                  ),
                  const SizedBox(height: 4),
                  Text(
                    message,
                    style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                          color: colors.onSurfaceVariant,
                        ),
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}
4
likes
0
points
116
downloads

Publisher

unverified uploader

Weekly Downloads

Skeleton loading screens for Flutter. Generates pixel-perfect shimmer placeholders from your real RenderObject tree.

Repository (GitHub)
View/report issues

Topics

#skeleton #shimmer #loading #placeholder #ui

License

unknown (license)

Dependencies

flutter

More

Packages that depend on hollow