Back to blog

Flutter Animation Tips to Boost App Engagement

Sukhrob Djumaev

-

June 18, 2024

I’m Sukhrob Djumaev, a Flutter developer at Ptolemay with over five years in mobile app development. Here’s what I’ve learned through years of practice:

Adding animations to your app significantly improves user retention. Users find animated apps more enjoyable and intuitive. Animations reinforce your brand identity. They also clearly explain features and ease the learning curve.

In this article, I’m excited to dive into the world of Flutter animations. I'll share top techniques to create smooth, compelling animations that make your app truly stand out. Whether enhancing onboarding flows, adding interactive button animations, or making data visualizations more engaging, these techniques will transform your app's user experience. Let's get started and elevate your app to the next level.

Importance of Animation in Apps

CheapOair uses an animated airplane progress bar to engage users during flight search results. Duolingo's onboarding animations make learning less intimidating. Asana delights users with a magical unicorn upon task completion, encouraging repeat use. Figma’s dynamic loading page keeps users informed with a colorful progress bar. Epicurious uses easing animations to reveal secondary buttons as users scroll. These animations play various roles and are crucial to these apps' popularity.

Importance of Animation in Apps

Let's explore the specific functions animations can serve in applications.

  1. Guiding User Interaction: Animations provide clear visual feedback, helping users understand actions and results. For instance, a button that enlarges slightly when pressed indicates successful interaction, making the app more intuitive.
  2. Smooth Transitions: Seamless transitions between app sections maintain user flow and reduce cognitive load. Smooth animated transitions help users feel connected to the app as elements move naturally.
  3. Highlighting Key Features: Animations draw attention to important features or updates. For example, a pulsing effect on a new feature icon can encourage exploration, increasing user engagement.
  4. Creating Delight: Delightful animations, like a confetti burst on task completion, enhance user satisfaction and encourage repeat use, boosting retention rates.

Additional Functions of Animations in Apps

Animations in apps can serve multiple functions that significantly enhance user experience and engagement. Here’s how:

  1. Visual Feedback: Animations confirm user actions, enhancing interactivity and providing immediate, understandable responses. A button that changes color or enlarges slightly when pressed gives users instant feedback that their action was recognized.
  2. Onboarding: Onboarding animations guide new users through the app’s features, making the learning process smoother. Use animations to highlight and explain key features during the initial app setup, making new users feel more comfortable and informed.
  3. Maintaining Context: Animations help users maintain context by visually showing changes and transitions within the app. Animate the transition between different sections of the app to help users understand the flow and maintain their context.
  4. Enhancing Storytelling: Animations can make the app's narrative more engaging and immersive. Use animations to illustrate a story or process within the app, keeping users engaged and interested.
  5. Branding: Consistent animations reinforce brand identity, making the user experience cohesive and memorable. Incorporate branded animations, such as a unique loading animation or transition effect, to strengthen brand recognition and consistency.
  6. User Engagement: Animations can boost user engagement by making interactions more enjoyable and intuitive. Interactive animations for buttons, icons, and other UI elements can make the app more engaging and fun to use.

Boost user engagement and protect data during app updates. Learn how in our article: How to Protect User Data When Updating Apps with Flutter.

Case Study - Animated Expenses Tracker App

Our expenses tracker app showcases the power of animations to enhance user experience. Key features include animated graphs, interactive expense items, and smooth page transitions. The use of animations not only makes the app visually appealing but also improves user engagement and satisfaction.

Main Application Setup

First, let's look at our main.dart file and MaterialApp setup:

  
    void main() {
      runApp(const MainApp());
    }

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

      @override
      Widget build(BuildContext context) {
        return ChangeNotifierProvider(
          create: (context) => GraphModel(),
          child: MaterialApp(
            onGenerateRoute: (settings) {
              if (settings.name == '/') {
                return MaterialPageRoute(
                  builder: (context) => const MainPage(),
                );
              } else if (settings.name == '/spending') {
                return PageRouteBuilder(
                  settings: settings,
                  pageBuilder: (context, animation, secondaryAnimation) => const SpendingPage(),
                  transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(
                    opacity: animation,
                    child: child,
                  ),
                );
              }
              return null;
            },
            theme: ThemeData(
              colorScheme: ColorScheme.fromSeed(
                seedColor: const Color(0xFF2D6BE6),
              ),
              textTheme: const TextTheme().apply(
                bodyColor: const Color(0xFF000000),
                displayColor: const Color(0xFF000000),
              ),
              fontFamily: 'Satoshi',
              scaffoldBackgroundColor: const Color(0xFFFFFFFF),
            ),
            debugShowCheckedModeBanner: false,
          ),
        );
      }
    }
  

Key Points:

  • Custom navigation using a fade transition for smooth animations.
  • Use of ChangeNotifierProvider from the provider package to manage state with GraphModel.

Data Models

Next, let's look at the data models for Month and Expense:


class Month {
  final String name;
  final String abbr;
  final List expenses;
  const Month({
    required this.name,
    required this.expenses,
    required this.abbr,
  });

  int get total => expenses.fold(0, (previousValue, element) => previousValue + element.amount);

  int get highest => expenses.reduce((current, next) => next.amount > current.amount ? next : current).amount;

  @override
  bool operator ==(covariant Month other) {
    if (identical(this, other)) return true;

    return other.name == name && other.abbr == abbr && listEquals(other.expenses, expenses);
  }

  @override
  int get hashCode => name.hashCode ^ abbr.hashCode ^ expenses.hashCode;
}

class Expense {
  final String name;
  final String abbr;
  final int amount;
  const Expense({
    required this.name,
    required this.abbr,
    required this.amount,
  });

  @override
  bool operator ==(covariant Expense other) {
    if (identical(this, other)) return true;

    return other.name == name && other.amount == amount && other.abbr == abbr;
  }

  @override
  int get hashCode => name.hashCode ^ abbr.hashCode ^ amount.hashCode;
}

State Management

This section includes the state management logic upon which our UI relies. We have selectMonthIndex and unselectMonthIndex methods for interaction.

class GraphModel extends ChangeNotifier {
  final List<Month> _expensesByMonth = expensesByMonth;
  int get _highestByMonths => highestByMonths;
  int get _totalByMonths => totalByMonths;

  int? selectedMonthIndex;

  String get title => selectedMonthIndex != null ? _expensesByMonth[selectedMonthIndex!].name : 'Total';
  int get total => selectedMonthIndex != null ? _expensesByMonth[selectedMonthIndex!].total : _totalByMonths;

  int get _length =>
      selectedMonthIndex != null ? _expensesByMonth[selectedMonthIndex!].expenses.length : _expensesByMonth.length;

  List<int> get indexes => List.generate(_length, (index) => index++);

  List<Expense> get expenses => selectedMonthIndex != null ? _expensesByMonth[selectedMonthIndex!].expenses : [];

  List<String> get barTitles => selectedMonthIndex != null
    ? _expensesByMonth[selectedMonthIndex!]
        .expenses
        .map(
          (e) => e.abbr,
        )
        .toList()
    : _expensesByMonth
        .map(
          (e) => e.abbr,
        )
        .toList();

  List<double> get barValuePercentage => selectedMonthIndex != null
    ? _expensesByMonth[selectedMonthIndex!]
        .expenses
        .map(
          (e) => e.amount / _expensesByMonth[selectedMonthIndex!].highest,
        )
        .toList()
    : _expensesByMonth
        .map(
          (e) => e.total / _highestByMonths,
        )
        .toList();

  void selectMonthIndex(int index) {
    selectedMonthIndex = index;
    notifyListeners();
  }

  void unselectMonthIndex() {
    selectedMonthIndex = null;
    notifyListeners();
  }
}

Main Page and Navigation

Now, let's continue with other parts of the UI, including the main page setup and custom bottom navigation bar:

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

  @override
  State<MainPage> createState() => _MainPageState();
}

class _MainPageState extends State<MainPage> {
  int pageIndex = 0;
  late final PageController _pageController;

  @override
  void initState() {
    _pageController = PageController();
    super.initState();
  }

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

  void _onBottomNavigationBarTap(int value) {
    _pageController.jumpToPage(value);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          PageView(
            controller: _pageController,
            children: const [
              HomePage(),
              Scaffold(body: Center(child: Text('Calculations'))),
              Scaffold(body: Center(child: Text('Favorites'))),
              Scaffold(body: Center(child: Text('Profile'))),
            ],
            onPageChanged: (value) => setState(() => pageIndex = value),
          ),
          Positioned(
            bottom: 0,
            child: AppBottomNavigationBar(
              selectedPageIndex: pageIndex,
              onTap: _onBottomNavigationBarTap,
            ),
          ),
        ],
      ),
    );
  }
}

The AppBottomNavigationBar widget defines the navigation items with animated containers for smooth transitions:

class AppBottomNavigationBar extends StatelessWidget {
  final int selectedPageIndex;
  final void Function(int value) onTap;
  const AppBottomNavigationBar({
    super.key,
    required this.selectedPageIndex,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: SizedBox(
        width: MediaQuery.of(context).size.width,
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20),
          child: Container(
            padding: const EdgeInsets.all(12),
            decoration: BoxDecoration(
              borderRadius: BorderRadius.circular(42),
              color: const Color(0xFFF4F6F9),
            ),
            child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  AppBottomNavigationBarItem(
                    onTap: () => onTap(0),
                    iconPath: 'assets/images/vector/nav_home.svg',
                    text: 'Home',
                    isSelected: selectedPageIndex == 0,
                  ),
                  AppBottomNavigationBarItem(
                    onTap: () => onTap(1),
                    iconPath: 'assets/images/vector/nav_percent.svg',
                    text: 'Calculation',
                    isSelected: selectedPageIndex == 1,
                  ),
                  AppBottomNavigationBarItem(
                    onTap: () => onTap(2),
                    iconPath: 'assets/images/vector/nav_heart.svg',
                    text: 'Favorites',
                    isSelected: selectedPageIndex == 2,
                  ),
                  AppBottomNavigationBarItem(
                    onTap: () => onTap(3),
                    iconPath: 'assets/images/vector/nav_profile.svg',
                    text: 'Profile',
                    isSelected: selectedPageIndex == 3,
                  ),
                ],
            ),
          ),
        ),
        ),
    );
  }
}
class AppBottomNavigationBarItem extends StatelessWidget {
  final String iconPath;
  final String text;
  final bool isSelected;
  final void Function() onTap;
  const AppBottomNavigationBarItem({
    super.key,
    required this.iconPath,
    required this.text,
    required this.isSelected,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    const animationDuration = Duration(milliseconds: 400);
    return GestureDetector(
      onTap: onTap,
      child: AnimatedContainer(
        duration: animationDuration,
        padding: EdgeInsets.symmetric(vertical: 10, horizontal: isSelected ? 12 : 10),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(36),
          color: isSelected ? const Color(0xFF2D6BE6) : null,
        ),
        child: Row(
          children: [
            SvgPicture.asset(
              iconPath,
              colorFilter: isSelected ? const ColorFilter.mode(Color(0xFFFFFFFF), BlendMode.srcIn) : null,
            ),
            AnimatedContainer(duration: animationDuration, width: 8),
            if (isSelected) ...[
              Text(
                text,
                style: const TextStyle(
                  fontSize: 14,
                  fontWeight: FontWeight.w500,
                  color: Color(0xFFFFFFFF),
                ),
              ),
            ],
          ],
        ),
      ),
    );
  }
}

The AnimatedContainer adds a smooth transition to our bottom navigation bar items, creating a visually appealing effect.

HomePage Implementation

The HomePage widget serves as the main dashboard of the application. It displays an overview of monthly expenses and allows users to navigate to detailed views.

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

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      appBar: HomeTopBar(),
      body: SafeArea(
        child: CustomScrollView(
          slivers: [
            SliverFillRemaining(
              hasScrollBody: false,
              child: Padding(
                padding: EdgeInsets.symmetric(horizontal: 20),
                child: Column(
                  children: [
                    SizedBox(height: 28),
                    Hero(tag: 'graph', child: Graph()),
                    SizedBox(height: 44),
                    Cards(),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

A key point here is the Hero widget wrapping the Graph widget. The same Hero widget is used on the Spending page, creating the effect that the Graph widget remains in place while the page changes.

class HomeTopBar extends StatelessWidget implements PreferredSizeWidget {
  const HomeTopBar({super.key});

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          const SizedBox(width: 20),
          ClipRRect(
            borderRadius: BorderRadius.circular(52),
            child: Image.asset(
              'assets/images/raster/avatar_1.png',
              width: 52,
              height: 52,
            ),
          ),
          const SizedBox(width: 8),
          const Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                'Hi, Lina',
                style: TextStyle(
                  fontSize: 20,
                  fontWeight: FontWeight.w500,
                ),
              ),
              Text(
                '16.02.2024',
                style: TextStyle(
                  fontSize: 16,
                  fontWeight: FontWeight.w500,
                  color: Color(0xFF717681),
                ),
              ),
            ],
          ),
          const Spacer(),
          InkWell(
            borderRadius: BorderRadius.circular(36),
            onTap: () {},
            child: Ink(
              padding: const EdgeInsets.all(14),
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(36),
                color: const Color(0xFFF4F6F9),
              ),
              child: SvgPicture.asset(
                'assets/images/vector/icon_notification.svg',
                width: 24,
                height: 24,
              ),
            ),
          ),
        const SizedBox(width: 20),
        ],
      ),
    );
  }

  @override
  Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
class Graph extends StatelessWidget { const Graph({super.key}); @override Widget build(BuildContext context) { final width = MediaQuery.of(context).size.width; final height = MediaQuery.of(context).size.height; final barWidth = (width - 40 - 50) / 6; return Material( type: MaterialType.transparency, child: Consumer( builder: (context, model, child) { final totalBarHeight = height / 5; return Column( mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ AnimatedContainer( duration: const Duration(milliseconds: 700), child: Text( model.title.toUpperCase(), style: const TextStyle( fontSize: 22, fontWeight: FontWeight.w700, ), ), ), const SizedBox(height: 4), const Text( 'money spending', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, color: Color(0xFF717681), ), ), ], ), GraphCounter( model.total, duration: const Duration(milliseconds: 400), ), ], ), const SizedBox(height: 24), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: model.indexes.map((index) { return GestureDetector( onTap: () async { if (model.selectedMonthIndex == null) { Navigator.of(context).pushNamed('/spending').then((_) async { await Future.delayed(const Duration(milliseconds: 400)); model.unselectMonthIndex(); }); await Future.delayed(const Duration(milliseconds: 400)); model.selectMonthIndex(index); } }, child: GraphItem( width: barWidth, title: model.barTitles[index], value: model.barValuePercentage[index] * totalBarHeight, totalValue: totalBarHeight, ), ); }).toList(), ), ), ], ); }, ), ); } }

The most crucial aspect of this widget is how we handle taps on GraphItems. When a GraphItem is tapped, we push a new page using the navigator, then wait 400 milliseconds before triggering the selectMonthIndex method. This delay allows the navigator to play its page transition, ensuring that the Graph widget appears unchanged in position. Additionally, we register a then callback on the pushNamed method of the Navigator, which is called whenever we return from the pushed page. This callback notifies our model and calls selectMonthIndex again, allowing time for the navigator's page transition animation.

class GraphItem extends StatelessWidget {
  final double width;
  final String title;
  final double value;
  final double totalValue;
  const GraphItem({
    super.key,
    required this.width,
    required this.title,
    required this.value,
    required this.totalValue,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(right: 10),
      child: Column(
        children: [
          SizedBox(
            height: totalValue,
            child: Align(
              alignment: Alignment.bottomCenter,
              child: AnimatedContainer(
                duration: const Duration(milliseconds: 400),
                width: width,
                height: value,
                decoration: const BoxDecoration(
                  borderRadius: BorderRadius.vertical(
                    top: Radius.circular(12),
                  ),
                  color: Color(0xFF2D6BE6),
                ),
              ),
            ),
          ),
          const SizedBox(
            height: 8,
          ),
          Text(
            title,
            style: const TextStyle(
              fontSize: 16,
              fontWeight: FontWeight.w500,
            ),
          ),
        ],
        ),
    );
  }
}

Here, you can see the use of AnimatedContainer to ensure the bars expand smoothly.

Our home page features a Cards widget, which contains the following content:

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

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Text(
              'my cards'.toUpperCase(),
              style: const TextStyle(
                fontSize: 22,
                fontWeight: FontWeight.w700,
              ),
            ),
            TextButton.icon(
              icon: SvgPicture.asset('assets/images/vector/icon_plus.svg'),
              onPressed: () {},
              label: const Text(
                'add card',
                style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: Color(0xFF2D6BE6)),
              ),
            ),
          ],
        ),
        const SizedBox(height: 20),
        const Stack(
          clipBehavior: Clip.none,
          children: [
            CardItem2(),
            CardItem(),
          ],
        ),
      ],
    );
  }
}
class CardItem2 extends StatelessWidget { const CardItem2({super.key}); @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.only(top: 58, left: 4, right: 4), height: 195, padding: const EdgeInsets.fromLTRB(23, 23, 23, 14), decoration: BoxDecoration( borderRadius: BorderRadius.circular(19), image: const DecorationImage( image: AssetImage('assets/images/raster/gradient_bg_2.png'), fit: BoxFit.cover, ), ), child: const Column( mainAxisAlignment: MainAxisAlignment.end, children: [ Row( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Family Card', style: TextStyle( fontSize: 20, fontWeight: FontWeight.w500, color: Color(0xFFFFFFFF), ), ), Text( '\$ 12,021', style: TextStyle( fontSize: 27, fontWeight: FontWeight.w900, color: Color(0xFFFFFFFF), ), ), ], ), ], ), ); } }
class CardItem2 extends StatelessWidget { const CardItem2({super.key}); @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.only(top: 58, left: 4, right: 4), height: 195, padding: const EdgeInsets.fromLTRB(23, 23, 23, 14), decoration: BoxDecoration( borderRadius: BorderRadius.circular(19), image: const DecorationImage( image: AssetImage('assets/images/raster/gradient_bg_2.png'), fit: BoxFit.cover, ), ), child: const Column( mainAxisAlignment: MainAxisAlignment.end, children: [ Row( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Family Card', style: TextStyle( fontSize: 20, fontWeight: FontWeight.w500, color: Color(0xFFFFFFFF), ), ), Text( '\$ 12,021', style: TextStyle( fontSize: 27, fontWeight: FontWeight.w900, color: Color(0xFFFFFFFF), ), ), ], ), ], ), ); } }

Now, lets move on to Spendings page.

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

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      appBar: SpendingTopBar(),
      body: SafeArea(
        child: CustomScrollView(
          slivers: [
            SliverFillRemaining(
              hasScrollBody: false,
              child: Padding(
                padding: EdgeInsets.symmetric(horizontal: 20),
                child: Column(
                  children: [
                    SizedBox(height: 28),
                    Hero(tag: 'graph', child: Graph()),
                    SizedBox(height: 36),
                    Expenses(),
                    SizedBox(height: 36),
                    History(),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

As mentioned earlier, the Hero widget is used here again to wrap the Graph widget.

class SpendingTopBar extends StatelessWidget implements PreferredSizeWidget {
  const SpendingTopBar({super.key});

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          const SizedBox(width: 20),
          InkWell(
            borderRadius: BorderRadius.circular(36),
            onTap: () {
              Navigator.pop(context);
            },
            child: Ink(
              padding: const EdgeInsets.all(14),
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(36),
                color: const Color(0xFFF4F6F9),
              ),
              child: SvgPicture.asset(
                'assets/images/vector/icon_back.svg',
                width: 24,
                height: 24,
              ),
            ),
          ),
          const Spacer(),
          const Text(
            'Spending',
            style: TextStyle(
                fontSize: 20,
                fontWeight: FontWeight.w500,
            ),
          ),
          const Spacer(),
          InkWell(
            borderRadius: BorderRadius.circular(36),
            onTap: () {},
            child: Ink(
              padding: const EdgeInsets.all(14),
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(36),
                color: const Color(0xFFF4F6F9),
              ),
              child: SvgPicture.asset(
                'assets/images/vector/icon_notification.svg',
                width: 24,
                height: 24,
              ),
            ),
          ),
          const SizedBox(width: 20),
        ],
      ),
    );
  }

  @override
  Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}

Here is the content of the Expenses widget, which is also animated and responds to changes from our GraphModel.

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

  @override
  Widget build(BuildContext context) {
    return Consumer<GraphModel>(
      builder: (BuildContext context, GraphModel model, Widget? child) {
        return SingleChildScrollView(
          scrollDirection: Axis.horizontal,
          child: AnimatedSwitcher(
            duration: const Duration(milliseconds: 700),
            transitionBuilder: (child, animation) {
              final slideAnimation = Tween<Offset>(begin: const Offset(1, 0), end: Offset.zero).animate(animation);
              return SlideTransition(
                position: slideAnimation,
                child: child,
              );
            },
            child: model.selectedMonthIndex == null
              ? const SizedBox()
              : Row(
                children: [
                  ...model.expenses.map(
                    (e) => ExpenseItem(item: e),
                  )
                ],
              ),
            ),
        );
      },
    );
  }
}
class ExpenseItem extends StatelessWidget {
  final Expense item;
  const ExpenseItem({
    super.key,
    required this.item,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 140,
      height: 140,
      margin: const EdgeInsets.only(right: 20),
      padding: const EdgeInsets.all(20),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(12),
        color: const Color(0x2B594BFB),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SvgPicture.asset('assets/images/vector/icon_handbag.svg'),
          const Spacer(),
          FittedBox(
            child: Text(
              item.name,
              style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500, color: Color(0xFF98A1B3)),
            ),
          ),
          Text(
            '$ ${item.amount}',
            style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700),
          ),
        ],
      ),
    );
  }
}

And finally, History widget, that is also animated based on GraphModel.

class History extends StatelessWidget { const History({super.key}); @override Widget build(BuildContext context) { return Consumer( builder: (BuildContext context, GraphModel model, Widget? child) { return AnimatedSwitcher( duration: const Duration(milliseconds: 700), transitionBuilder: (child, animation) { final slideAnimation = Tween(begin: const Offset(0, 1), end: Offset.zero).animate(animation); return SlideTransition( position: slideAnimation, child: child, ); }, child: model.selectedMonthIndex == null ? const SizedBox() : Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'history'.toUpperCase(), style: const TextStyle( fontSize: 22, fontWeight: FontWeight.w700, ), ), TextButton( onPressed: () {}, child: const Text( 'view all', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: Color(0xFF2D6BE6)), ), ), ], ), const HistoryItem(), const HistoryItem(), const HistoryItem(), const HistoryItem(), ], ), ); }, ); } }
class HistoryItem extends StatelessWidget { const HistoryItem({super.key}); @override Widget build(BuildContext context) { return Column( children: [ const SizedBox(height: 20), Row( children: [ Image.asset( 'assets/images/raster/logo_adidas.png', width: 48, height: 48, ), const SizedBox(width: 18), const Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Shopping at adidas', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, ), ), SizedBox(height: 4), Text( '**** 3456', style: TextStyle( fontSize: 16, color: Color(0xFF717681), ), ), ], ), ), const Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( '- \$200', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, ), ), SizedBox(height: 4), Text( '01 Mar', style: TextStyle( fontSize: 16, color: Color(0xFF717681), ), ), ], ) ], ), const SizedBox(height: 8), const Divider(color: Color(0xFFEBEDF1), height: 1), ], ); } }

Built-in Animation Libraries

Flutter provides a robust set of built-in animation libraries that make it easy to add dynamic, engaging effects to your applications. These built-in tools offer various ways to create animations with minimal code.

Overview of Flutter’s Built-in Animation Widgets:

  1. AnimatedContainer: This widget allows you to animate changes to its properties, such as size, color, and padding. When any of these properties are modified, AnimatedContainer automatically animates the changes over a specified duration.
  2. AnimatedOpacity: This widget smoothly transitions the opacity of a child widget, useful for fade-in and fade-out effects.
  3. AnimatedBuilder: This widget is more flexible, allowing you to create complex animations by separating the animation's definition from the widget tree. It listens to the animation and rebuilds the widget tree when the animation value changes.
  4. AnimatedList: This widget helps animate the addition, removal, and changes of list items.
  5. AnimatedPositioned: This widget animates its position when the specified properties change, great for animating positional shifts.

These built-in widgets streamline the animation process, providing out-of-the-box solutions for common animation needs.

Third-party Packages

To add more sophisticated animations, Flutter supports various third-party packages. These libraries extend the capabilities of built-in tools, enabling developers to implement complex and visually impressive animations.

Popular Third-party Packages:

  1. Rive: Rive (formerly known as Flare) is a powerful animation tool that allows designers to create complex vector animations and directly import them into Flutter. It supports interactive animations that respond to user input.
  2. Lottie: Lottie by Airbnb allows you to use animations created in Adobe After Effects and exported as JSON files using Bodymovin. Lottie files are lightweight and scalable, making them perfect for creating rich animations without a significant performance hit.
  3. Auto_animated: This package makes it simple to animate list items as they are added or removed from the view. It includes pre-built animations for common use cases.
  4. Staggered Animations: This library makes it easy to create staggered animations, where multiple animations are played in sequence or with a delay between them, ideal for animating list or grid items.
  5. Shimmer: Shimmer is used to create beautiful loading indicators with a shimmering effect. This is particularly useful for enhancing the user experience during data loading.
  6. Flutter Sequence Animation: This package allows you to create complex sequences of animations without manually calculating the time offsets, making it easier to manage and synchronize multiple animations.

Example: Integrating Lottie Animations in a Flutter App:

import 'package:lottie/lottie.dart'; Lottie.asset( 'assets/animation.json', width: 200, height: 200, fit: BoxFit.fill, );

Using these libraries, you can create rich, interactive animations that significantly enhance the visual appeal and user experience of your Flutter applications.

Benefits of Using Third-party Animation Libraries:

  • Enhanced Visual Appeal: Tools like Rive and Lottie enable the creation of intricate animations that would be challenging to achieve with built-in widgets alone.
  • Efficiency: These libraries often come with pre-built animations and tools that reduce development time.
  • Interactivity: Many third-party libraries support advanced interactive animations, improving user engagement.

By leveraging both built-in and third-party tools, developers can create compelling and responsive animations that enhance the overall user experience and engagement in their Flutter applications

Challenges and Considerations

When integrating animations into Flutter apps, developers often face performance challenges that must be addressed to ensure a smooth user experience. Here are the key issues and strategies to mitigate them:

  1. High CPU Usage: Animations, especially complex ones, can consume significant CPU resources, leading to slower performance and increased battery consumption. This can be mitigated by optimizing code, using efficient algorithms, and leveraging asynchronous programming to offload work from the main thread.
  2. Memory Consumption: Animations can increase memory usage, potentially leading to app crashes or slowdowns. Proper memory management, such as cleaning up unused objects and using efficient data structures, helps reduce memory footprint.
  3. Frame Jank: Flutter apps can experience frame drops or jank, which are often caused by heavy computations during the frame build process. Minimizing rebuilds and optimizing widget trees are crucial for maintaining smooth animations. Utilizing tools like the Flutter DevTools can help identify and resolve performance bottlenecks.
  4. Shader Compilation Jank: Initial shader compilations can cause noticeable delays. Pre-compiling shaders or using warm-up shaders can alleviate this issue, ensuring that animations run smoothly from the start.

To optimize Flutter animations, keep them simple and avoid heavy calculations. Use AnimatedBuilder and AnimatedContainer for efficiency. Regularly use Flutter DevTools to identify and fix performance issues. Enable GPU acceleration to improve rendering. Minimize overdraw by optimizing your widget tree. Ensure animations have clear purposes, provide options to reduce motion, and maintain consistency in styles. These steps will enhance both performance and user experience.

Why Choose Ptolemay for Your App Development

Why Choose Ptolemay for Your App Development

With a decade of experience, Ptolemay excels in mobile app development, particularly in Flutter and AI integration. We've successfully launched over 70 products, including Togeza, a social app for couples, and several e-commerce apps, significantly boosting user engagement. Our work with clients like Sézane highlights our ability to create intuitive, engaging apps that drive business growth.

Ptolemay offers full-cycle development services from concept to deployment, including strategy consulting, design, development, testing, and post-launch support. We tailor our services to meet each client's unique needs, ensuring high-quality, user-friendly apps that align with strategic goals.

Final Thoughts

Animations are crucial for modern app development, boosting user satisfaction and engagement. Partner with Ptolemay for expert development services to bring your app vision to life. Our proven experience and dedication to quality make us the perfect choice. Contact us and let's start building your app!