Deixe seu app do Flutter lindo, não chato

1. Introdução

O Flutter é um kit de ferramentas de interface do Google para criar apps incríveis e nativos para dispositivos móveis, Web e computadores com uma única base de código. Ele funciona com código existente, é usado por desenvolvedores e organizações do mundo todo, é de código aberto e não tem custos.

Neste codelab, você vai aprimorar um aplicativo de música do Flutter, transformando-o de chato em lindo. Para isso, este codelab usa ferramentas e APIs introduzidas no Material 3 (link em inglês).

O que você vai aprender

  • Como criar um app do Flutter fácil de usar e com uma linda aparência em várias plataformas.
  • Como usar texto no app para garantir que ele melhore a experiência do usuário.
  • Como escolher as cores certas, personalizar widgets, criar seu próprio tema e implementar o modo escuro de forma rápida e fácil.
  • Como criar apps adaptáveis multiplataforma.
  • Como criar apps com uma boa aparência em qualquer tela.
  • Como adicionar movimento ao app do Flutter para que ele fique realmente incrível.

Pré-requisitos:

Este codelab presume que você tem alguma experiência com o Flutter. Caso contrário, talvez seja melhor aprender o básico primeiro. Confira alguns links úteis:

O que você vai criar

Este codelab ajudará você a criar a tela inicial de um app de música chamado MyArtist, em que fãs podem acompanhar os artistas favoritos deles. Ele mostra como modificar o design do app para que fique bonito em várias plataformas.

Os GIFs animados abaixo mostram como o app vai ficar ao concluir este codelab:

4a0f6509a18aaf30.gif 1557a5d9dab19d75.gif

O que você quer aprender com este codelab?

Ainda não conheço bem o assunto e quero ter uma boa visão geral. Conheço um pouco sobre esse assunto, mas quero me atualizar. Estou procurando exemplos de código para usar no meu projeto. Estou procurando uma explicação de algo específico.

2. Configurar o ambiente de desenvolvimento do Flutter

Você precisa de dois softwares para concluir este codelab: o SDK do Flutter e um editor (links em inglês).

É possível executar o código deste codelab usando qualquer um dos dispositivos abaixo:

  • Um dispositivo físico Android ou iOS (links em inglês) conectado ao seu computador e configurado para o modo de desenvolvedor.
  • O simulador de iOS (link em inglês), que exige a instalação de ferramentas do Xcode.
  • O Android Emulator (link em inglês), que exige a configuração no Android Studio.
  • Um navegador (o Chrome é necessário para depuração).
  • Como em aplicativos para computador Windows, Linux ou macOS (links em inglês), você precisa desenvolver na plataforma em que planeja implantar. Se quiser desenvolver um app para computadores Windows, você terá que fazer isso nesse sistema operacional para ter acesso à cadeia de build adequada. Há requisitos específicos para cada sistema operacional que são abordados em detalhes em docs.flutter.dev/desktop (link em inglês).

3. Instalar o app inicial do codelab

Clonar do GitHub

Para clonar este codelab do GitHub, execute estes comandos:

git clone https://github.com/flutter/codelabs.git
cd codelabs/boring_to_beautiful/step_01/

Para garantir que tudo esteja funcionando, execute o aplicativo do Flutter como um aplicativo para computador, conforme mostrado abaixo. Como alternativa, abra esse projeto no seu ambiente de desenvolvimento integrado e use as ferramentas dele para executar o aplicativo.

a3c16fc17be25f6c.png Executar o app (link em inglês).

Pronto! O código inicial da tela inicial do MyArtist vai ser executado. A tela inicial do MyArtist vai aparecer. Fica bom no computador, mas no smartphone… Não está legal. Para começar, ele não se adequa ao entalhe do dispositivo. Não se preocupe, você vai corrigir o problema.

9ebe486bc7dfa36b.png 1b30e16df3cde215.png

Tour do código

Em seguida, faça um tour pelo código.

Abra o arquivo lib/src/features/home/view/home_screen.dart (link em inglês), que contém o seguinte:

lib/src/features/home/view/home_screen.dart (link em inglês)

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

import '../../../shared/classes/classes.dart';
import '../../../shared/extensions.dart';
import '../../../shared/providers/providers.dart';
import '../../../shared/views/views.dart';
import '../../playlists/view/playlist_songs.dart';
import 'view.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({Key? key}) : super(key: key);

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  @override
  Widget build(BuildContext context) {
    final PlaylistsProvider playlistProvider = PlaylistsProvider();
    final List<Playlist> playlists = playlistProvider.playlists;
    final Playlist topSongs = playlistProvider.topSongs;
    final Playlist newReleases = playlistProvider.newReleases;
    final ArtistsProvider artistsProvider = ArtistsProvider();
    final List<Artist> artists = artistsProvider.artists;
    return LayoutBuilder(
      builder: (context, constraints) {
        // Add conditional mobile layout

        return Scaffold(
          body: SingleChildScrollView(
            child: AdaptiveColumn(
              children: [
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Padding(
                    padding: const EdgeInsets.all(2), // Modify this line
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        Expanded(
                          child: Text(
                            'Good morning',
                            style: context.displaySmall,
                          ),
                        ),
                        const SizedBox(width: 20),
                        const BrightnessToggle(),
                      ],
                    ),
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Column(
                    children: [
                      const HomeHighlight(),
                      LayoutBuilder(
                        builder: (context, constraints) => HomeArtists(
                          artists: artists,
                          constraints: constraints,
                        ),
                      ),
                    ],
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Padding(
                        padding: const EdgeInsets.all(2), // Modify this line
                        child: Text(
                          'Recently played',
                          style: context.headlineSmall,
                        ),
                      ),
                      HomeRecent(
                        playlists: playlists,
                      ),
                    ],
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Padding(
                    padding: const EdgeInsets.all(2), // Modify this line
                    child: Row(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Flexible(
                          flex: 10,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.start,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Padding(
                                padding:
                                    const EdgeInsets.all(2), // Modify this line
                                child: Text(
                                  'Top Songs Today',
                                  style: context.titleLarge,
                                ),
                              ),
                              LayoutBuilder(
                                builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: topSongs,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                        // Add spacer between tables
                        Flexible(
                          flex: 10,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.start,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Padding(
                                padding:
                                    const EdgeInsets.all(2), // Modify this line
                                child: Text(
                                  'New Releases',
                                  style: context.titleLarge,
                                ),
                              ),
                              LayoutBuilder(
                                builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: newReleases,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}

Esse arquivo importa material.dart e implementa um widget com estado usando duas classes:

  • A instrução import disponibiliza os componentes do Material Design.
  • A classe HomeScreen representa a página inteira mostrada.
  • O método build() da classe _HomeScreenState cria a raiz da árvore de widgets, o que afeta a criação de todos os widgets na interface.

4. Aproveitar a tipografia

O texto é usado em todos os lugares como uma forma útil de se comunicar com o usuário. Seu app pretender ser amigável e divertido ou talvez confiável e profissional? Existe uma razão para seu app bancário favorito não usar Comic Sans. A forma como o texto é apresentado molda a primeira impressão do usuário sobre o app. Confira algumas maneiras de usar o texto com mais atenção.

Não diga, mostre

Sempre que possível, "mostre" em vez de "dizer". Por exemplo, a NavigationRail no app inicial tem guias para cada categoria principal, mas os ícones delas são idênticos:

86c5f73b3aa5fd35.png

Isso não é útil porque o usuário ainda precisa ler o texto de cada guia. Comece adicionando indicações visuais para que o usuário possa conferir rapidamente os ícones e encontrar a guia desejada. Isso também ajuda na localização e na acessibilidade.

a3c16fc17be25f6c.png Em lib/src/shared/router.dart (link em inglês), adicione ícones diferentes para cada destino de navegação (página inicial, playlist e pessoas):

lib/src/shared/router.dart (link em inglês)

const List<NavigationDestination> destinations = [
  NavigationDestination(
    label: 'Home',
    icon: Icon(Icons.home), // Modify this line
    route: '/',
  ),
  NavigationDestination(
    label: 'Playlists',
    icon: Icon(Icons.playlist_add_check), // Modify this line
    route: '/playlists',
  ),
  NavigationDestination(
    label: 'Artists',
    icon: Icon(Icons.people), // Modify this line
    route: '/artists',
  ),
];

23278e4f4610fbf4.png

Problemas?

Caso o app não esteja executando corretamente, verifique se há erros de digitação. Se necessário, use o código nos links abaixo para colocar tudo de volta nos eixos.

Escolha bem as fontes

As fontes definem a personalidade do seu aplicativo. Escolher a fonte certa é crucial. Ao selecionar uma fonte, considere o seguinte:

  • Sans Serif ou serif: as fontes Serif têm traços ou decorações no final das letras que são consideradas mais formais. As fontes Sans Serif não têm traços decorativos e costumam ser consideradas mais informais. 34bf54e4cad90101.png Um T maiúsculo em Sans Serif e outro em serif
  • Todas as fontes em letras maiúsculas: usar letras maiúsculas é adequado para chamar a atenção para pequenas quantidades de texto, como títulos. No entanto, quando usado em excesso, isso pode ser entendido como gritos, fazendo o usuário ignorar o texto.
  • Letras maiúsculas no estilo padrão de títulos ou de frases: ao adicionar títulos ou rótulos, considere o modo como você usa letras maiúsculas. O padrão de títulos em inglês, em que a primeira letra de cada palavra é maiúscula ("Este É um Título no Estilo Padrão de Títulos em Inglês"), é mais formal. O estilo padrão de frases usa letras maiúsculas apenas para substantivos próprios e a primeira palavra do texto ("Esse é o estilo de frase"). Esse estilo é considerado mais informal e parece uma conversa.
  • Uso de kerning (espaço entre as letras), comprimento da linha (largura do texto completo na tela) e altura da linha (qual é a altura de cada linha do texto): muito pouco ou em excesso torna o app menos legível. Por exemplo, é fácil se perder ao ler um bloco de texto grande e ininterrupto.

Com isso em mente, acesse o Google Fonts e escolha uma fonte Sans Serif, como Montserrat, já que o app de música precisa ser divertido.

a3c16fc17be25f6c.png Na linha de comando, extraia o pacote google_fonts. Isso também atualiza o arquivo pubspec para adicionar as fontes como uma dependência de app.

$ flutter pub add google_fonts

macos/Runner/DebugProfile.entitlements (link em inglês)

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.cs.allow-jit</key>
        <true/>
// Make sure these lines are present from here...
        <key>com.apple.security.network.client</key> //For macOS only
        <true/>
// .. To here
        <key>com.apple.security.network.server</key>
        <true/>
</dict>
</plist>

a3c16fc17be25f6c.png No lib/src/shared/extensions.dart (link em inglês), importe o novo pacote:

lib/src/shared/extensions.dart (link em inglês)

import 'package:google_fonts/google_fonts.dart';  // Add this line.

a3c16fc17be25f6c.png Defina o TextTheme: como Montserrat

TextTheme get textTheme => GoogleFonts.montserratTextTheme(theme.textTheme); // Modify this line

a3c16fc17be25f6c.png Faça uma recarga automática de 7f9a9e103c7b5e5.png para ativar as mudanças. Use o botão no seu ambiente de desenvolvimento integrado ou, na linha de comando, digite r para fazer uma recarga automática.

ff6f09f4cc39c21e.png

Você vai perceber que os novos ícones da NavigationRail aparecem junto do texto na fonte Montserrat.

Problemas?

Caso o app não esteja executando corretamente, verifique se há erros de digitação. Se necessário, use o código nos links abaixo para colocar tudo de volta nos eixos.

5. Definir o tema

Os temas ajudam a dar um design estruturado e uniformidade para um app especificando um sistema definido de cores e estilos de texto. Os temas permitem que você implemente uma interface com rapidez sem precisar se preocupar com detalhes menores, como especificar a cor exata para cada widget.

Os desenvolvedores do Flutter costumam criar componentes personalizados de duas maneiras:

  • Criar widgets personalizados individuais, cada um com um tema próprio.
  • Criar temas com escopo para widgets padrão.

Este exemplo usa um provedor de temas localizado em lib/src/shared/providers/theme.dart (link em inglês) para criar widgets e cores com tema consistente em todo o app:

lib/src/shared/providers/theme.dart (link em inglês)

import 'dart:math';

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

class NoAnimationPageTransitionsBuilder extends PageTransitionsBuilder {
 const NoAnimationPageTransitionsBuilder();

 @override
 Widget buildTransitions<T>(
   PageRoute<T> route,
   BuildContext context,
   Animation<double> animation,
   Animation<double> secondaryAnimation,
   Widget child,
 ) {
   return child;
 }
}

class ThemeSettingChange extends Notification {
 ThemeSettingChange({required this.settings});
 final ThemeSettings settings;
}

class ThemeProvider extends InheritedWidget {
 const ThemeProvider(
     {Key? key,
     required this.settings,
     required this.lightDynamic,
     required this.darkDynamic,
     required Widget child})
     : super(key: key, child: child);

 final ValueNotifier<ThemeSettings> settings;
 final ColorScheme? lightDynamic;
 final ColorScheme? darkDynamic;

 final pageTransitionsTheme = const PageTransitionsTheme(
   builders: <TargetPlatform, PageTransitionsBuilder>{
     TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
     TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
     TargetPlatform.linux: NoAnimationPageTransitionsBuilder(),
     TargetPlatform.macOS: NoAnimationPageTransitionsBuilder(),
     TargetPlatform.windows: NoAnimationPageTransitionsBuilder(),
   },
 );

 Color custom(CustomColor custom) {
   if (custom.blend) {
     return blend(custom.color);
   } else {
     return custom.color;
   }
 }

 Color blend(Color targetColor) {
   return Color(
       Blend.harmonize(targetColor.value, settings.value.sourceColor.value));
 }

 Color source(Color? target) {
   Color source = settings.value.sourceColor;
   if (target != null) {
     source = blend(target);
   }
   return source;
 }

 ColorScheme colors(Brightness brightness, Color? targetColor) {
   final dynamicPrimary = brightness == Brightness.light
       ? lightDynamic?.primary
       : darkDynamic?.primary;
   return ColorScheme.fromSeed(
     seedColor: dynamicPrimary ?? source(targetColor),
     brightness: brightness,
   );
 }

 ShapeBorder get shapeMedium => RoundedRectangleBorder(
       borderRadius: BorderRadius.circular(8),
     );

 CardTheme cardTheme() {
   return CardTheme(
     elevation: 0,
     shape: shapeMedium,
     clipBehavior: Clip.antiAlias,
   );
 }

 ListTileThemeData listTileTheme(ColorScheme colors) {
   return ListTileThemeData(
     shape: shapeMedium,
     selectedColor: colors.secondary,
   );
 }

 AppBarTheme appBarTheme(ColorScheme colors) {
   return AppBarTheme(
     elevation: 0,
     backgroundColor: colors.surface,
     foregroundColor: colors.onSurface,
   );
 }

 TabBarTheme tabBarTheme(ColorScheme colors) {
   return TabBarTheme(
     labelColor: colors.secondary,
     unselectedLabelColor: colors.onSurfaceVariant,
     indicator: BoxDecoration(
       border: Border(
         bottom: BorderSide(
           color: colors.secondary,
           width: 2,
         ),
       ),
     ),
   );
 }

 BottomAppBarTheme bottomAppBarTheme(ColorScheme colors) {
   return BottomAppBarTheme(
     color: colors.surface,
     elevation: 0,
   );
 }

 BottomNavigationBarThemeData bottomNavigationBarTheme(ColorScheme colors) {
   return BottomNavigationBarThemeData(
     type: BottomNavigationBarType.fixed,
     backgroundColor: colors.surfaceVariant,
     selectedItemColor: colors.onSurface,
     unselectedItemColor: colors.onSurfaceVariant,
     elevation: 0,
     landscapeLayout: BottomNavigationBarLandscapeLayout.centered,
   );
 }

 NavigationRailThemeData navigationRailTheme(ColorScheme colors) {
   return const NavigationRailThemeData();
 }

 DrawerThemeData drawerTheme(ColorScheme colors) {
   return DrawerThemeData(
     backgroundColor: colors.surface,
   );
 }

 ThemeData light([Color? targetColor]) {
   final _colors = colors(Brightness.light, targetColor);
   return ThemeData.light().copyWith(
     pageTransitionsTheme: pageTransitionsTheme,
     colorScheme: _colors,
     appBarTheme: appBarTheme(_colors),
     cardTheme: cardTheme(),
     listTileTheme: listTileTheme(_colors),
     bottomAppBarTheme: bottomAppBarTheme(_colors),
     bottomNavigationBarTheme: bottomNavigationBarTheme(_colors),
     navigationRailTheme: navigationRailTheme(_colors),
     tabBarTheme: tabBarTheme(_colors),
     drawerTheme: drawerTheme(_colors),
     scaffoldBackgroundColor: _colors.background,
     useMaterial3: true,
   );
 }

 ThemeData dark([Color? targetColor]) {
   final _colors = colors(Brightness.dark, targetColor);
   return ThemeData.dark().copyWith(
     pageTransitionsTheme: pageTransitionsTheme,
     colorScheme: _colors,
     appBarTheme: appBarTheme(_colors),
     cardTheme: cardTheme(),
     listTileTheme: listTileTheme(_colors),
     bottomAppBarTheme: bottomAppBarTheme(_colors),
     bottomNavigationBarTheme: bottomNavigationBarTheme(_colors),
     navigationRailTheme: navigationRailTheme(_colors),
     tabBarTheme: tabBarTheme(_colors),
     drawerTheme: drawerTheme(_colors),
     scaffoldBackgroundColor: _colors.background,
     useMaterial3: true,
   );
 }

 ThemeMode themeMode() {
   return settings.value.themeMode;
 }

 ThemeData theme(BuildContext context, [Color? targetColor]) {
   final brightness = MediaQuery.of(context).platformBrightness;
   return brightness == Brightness.light
       ? light(targetColor)
       : dark(targetColor);
 }

 static ThemeProvider of(BuildContext context) {
   return context.dependOnInheritedWidgetOfExactType<ThemeProvider>()!;
 }

 @override
 bool updateShouldNotify(covariant ThemeProvider oldWidget) {
   return oldWidget.settings != settings;
 }
}

class ThemeSettings {
 ThemeSettings({
   required this.sourceColor,
   required this.themeMode,
 });

 final Color sourceColor;
 final ThemeMode themeMode;
}

Color randomColor() {
 return Color(Random().nextInt(0xFFFFFFFF));
}

// Custom Colors
const linkColor = CustomColor(
 name: 'Link Color',
 color: Color(0xFF00B0FF),
);

class CustomColor {
 const CustomColor({
   required this.name,
   required this.color,
   this.blend = true,
 });

 final String name;
 final Color color;
 final bool blend;

 Color value(ThemeProvider provider) {
   return provider.custom(this);
 }
}

a3c16fc17be25f6c.pngPara usar o provedor, crie uma instância e transmita-a para o objeto do tema com escopo no MaterialApp, localizado em lib/src/shared/app.dart (link em inglês). Ele será herdado por qualquer objeto Theme aninhado:

lib/src/shared/app.dart (link em inglês)

import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import 'playback/bloc/bloc.dart';
import 'providers/theme.dart';
import 'router.dart';

class MyApp extends StatefulWidget {
 const MyApp({Key? key}) : super(key: key);

 @override
 State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
 final settings = ValueNotifier(ThemeSettings(
   sourceColor:  Colors.pink,
   themeMode: ThemeMode.system,
 ));
 @override
 Widget build(BuildContext context) {
   return BlocProvider<PlaybackBloc>(
     create: (context) => PlaybackBloc(),
     child: DynamicColorBuilder(
       builder: (lightDynamic, darkDynamic) => ThemeProvider(
           lightDynamic: lightDynamic,
           darkDynamic: darkDynamic,
           settings: settings,
           child: NotificationListener<ThemeSettingChange>(
             onNotification: (notification) {
               settings.value = notification.settings;
               return true;
             },
             child: ValueListenableBuilder<ThemeSettings>(
               valueListenable: settings,
               builder: (context, value, _) {
                 final theme = ThemeProvider.of(context); // Add this line
                 return MaterialApp.router(
                   debugShowCheckedModeBanner: false,
                   title: 'Flutter Demo',
                   theme: theme.light(settings.value.sourceColor), // Add this line
                   routeInformationParser: appRouter.routeInformationParser,
                   routerDelegate: appRouter.routerDelegate,
                 );
               },
             ),
           )),
     ),
   );
 }
}

Agora que o tema está configurado, escolha cores para o app.

Escolher o conjunto certo de cores nem sempre é fácil. Você pode ter uma ideia da cor principal, mas é provável que queira que o app tenha mais de uma cor. De que cor vai ser o texto? E o título? O conteúdo? Os links? E a cor de fundo? O Material Theme Builder é uma ferramenta baseada na Web (introduzida no Material 3) que ajuda a selecionar um conjunto de cores complementares para o app.

a3c16fc17be25f6c.pngPara escolher uma cor de origem para o app, abra o Material Theme Builder (link em inglês) e explore cores diferentes para a interface. É importante selecionar uma cor adequada à estética da marca e/ou à sua preferência pessoal.

Depois de criar um tema, clique com o botão direito do mouse no balão da cor Primary (principal). Isso abre uma caixa de diálogo com o valor hexadecimal da cor principal. Copie esse valor. Você também pode definir a cor usando essa caixa de diálogo.

a6201933c4be275c.gif

a3c16fc17be25f6c.pngTransmita o valor hexadecimal da cor principal ao provedor de tema. Por exemplo, a cor hexadecimal #00cbe6 é especificada como Color(0xff00cbe6). O ThemeProvider gera um ThemeData que contém o conjunto de cores complementares que você visualizou no Material Theme Builder:

final settings = ValueNotifier(ThemeSettings(
   sourceColor:  Color(0xff00cbe6), // Replace this color
   themeMode: ThemeMode.system,
 ));

Reinicie o app. Com a cor principal definida, ele começa a ficar mais expressivo. Para acessar todas as novas cores, faça referência ao tema no contexto e capture o ColorScheme:

final colors = Theme.of(context).colorScheme;

a3c16fc17be25f6c.pngPara usar uma cor específica, acesse uma função de cor no colorScheme. Acesse lib/src/shared/views/outlined_card.dart (link em inglês) e adicione uma borda ao OutlinedCard:

lib/src/shared/views/outlined_card.dart (link em inglês)

class _OutlinedCardState extends State<OutlinedCard> {
  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      cursor: widget.clickable
          ? SystemMouseCursors.click
          : SystemMouseCursors.basic,
      child: Container(
        child: widget.child,
        // Add from here...
        decoration: BoxDecoration(
          border: Border.all(
            color: Theme.of(context).colorScheme.outline,
            width: 1,
          ),
        ),
        // ... To here.
      ),
    );
  }
}

O Material 3 introduz funções de cor diferenciadas que se complementam e podem ser usadas em toda a interface para adicionar novas camadas de expressão. Essas novas funções de cor incluem:

  • Primary, OnPrimary, PrimaryContainer, OnPrimaryContainer
  • Secondary, OnSecondary, SecondaryContainer, OnSecondaryContainer
  • Tertiary, OnTertiary, TertiaryContainer, OnTertiaryContainer
  • Error, OnError, ErrorContainer, OnErrorContainer
  • Background, OnBackground
  • Surface, OnSurface, SurfaceVariant, OnSurfaceVariant
  • Shadow, Outline, InversePrimary

Além disso, os novos tokens de design (link em inglês) oferecem suporte para os temas claro e escuro:

7b51703ed96196a4.png

É possível usar essas funções de cor para atribuir significado e ênfase a diferentes partes da interface. Mesmo que um componente não esteja em destaque, ele ainda pode usar a cor dinâmica.

a3c16fc17be25f6c.png O usuário pode definir o brilho do app nas configurações do sistema do dispositivo. No lib/src/shared/app.dart (link em inglês), quando o dispositivo estiver no modo escuro, retorne um tema escuro e o modo do tema para o MaterialApp.

lib/src/shared/app.dart (link em inglês)

return MaterialApp.router(
  debugShowCheckedModeBanner: false,
  title: 'Flutter Demo',
  theme: theme.light(settings.value.sourceColor),
  darkTheme: theme.dark(settings.value.sourceColor), // Add this line
  themeMode: theme.themeMode(), // Add this line
  routeInformationParser: appRouter.routeInformationParser,
  routerDelegate: appRouter.routerDelegate,
);

Clique no ícone de lua no canto superior direito para ativar o modo escuro.

60ad6e64df0c5957.gif

Problemas?

Caso seu app não esteja sendo executado corretamente, use o código dos links abaixo para colocar tudo de volta nos eixos.

6. Adicionar design adaptável

Com o Flutter, você pode criar apps que funcionam em praticamente qualquer dispositivo, mas isso não quer dizer que todos eles vão se comportar da mesma forma em todos os lugares. Os usuários esperam diferentes comportamentos e recursos de diferentes plataformas.

O Material Design oferece pacotes para facilitar o trabalho com layouts adaptáveis. Encontre esses pacotes do Flutter no GitHub (link em inglês).

Considere as seguintes diferenças de plataforma ao criar um app adaptativo multiplataforma:

  • Método de entrada: mouse, toque ou gamepad
  • Tamanho da fonte, orientação do dispositivo e distância de visualização
  • Tamanho e formato da tela: smartphone, tablet, dobrável, computador, Web

a3c16fc17be25f6c.png O arquivo lib/src/shared/views/adaptive_navigation.dart (link em inglês) contém uma classe de navegação em que você pode fornecer uma lista de destinos e conteúdo para renderizar o corpo. Como você usa esse layout em várias telas, há um layout base compartilhado para ser transmitido a cada filho. As colunas de navegação são boas para computadores e telas grandes. No entanto, é possível tornar o layout compatível com dispositivos móveis mostrando uma barra de navegação na parte de baixo.

lib/src/shared/views/adaptive_navigation.dart (link em inglês)

import 'package:flutter/material.dart';

class AdaptiveNavigation extends StatelessWidget {
  const AdaptiveNavigation({
    Key? key,
    required this.destinations,
    required this.selectedIndex,
    required this.onDestinationSelected,
    required this.child,
  }) : super(key: key);

  final List<NavigationDestination> destinations;
  final int selectedIndex;
  final void Function(int index) onDestinationSelected;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, dimens) {
        // Tablet Layout
        if (dimens.maxWidth >= 600) { // Add this line
          return Scaffold(
            body: Row(
              children: [
                NavigationRail(
                  extended: dimens.maxWidth >= 800,
                  minExtendedWidth: 180,
                  destinations: destinations
                      .map((e) => NavigationRailDestination(
                            icon: e.icon,
                            label: Text(e.label),
                          ))
                      .toList(),
                  selectedIndex: selectedIndex,
                  onDestinationSelected: onDestinationSelected,
                ),
                Expanded(child: child),
              ],
            ),
          );
        } // Add this line

        // Mobile Layout
        // Add from here...
        return Scaffold(
          body: child,
          bottomNavigationBar: NavigationBar(
            destinations: destinations,
            selectedIndex: selectedIndex,
            onDestinationSelected: onDestinationSelected,
          ),
        );
        // ... To here.
      },
    );
  }
}

d1825d6f0d23314.png

Nem todas as telas são do mesmo tamanho. Se você tentar apresentar a versão para computador do app no smartphone, será necessário fazer uma combinação de apertar e aplicar zoom para mostrar tudo. O ideal é que seu app mude de aparência com base na tela em que é exibido. Com o design responsivo, você garante que o app tenha uma ótima aparência em telas de todos os tamanhos.

Para tornar o app responsivo, inclua alguns pontos de interrupção adaptáveis. Não os confunda com pontos de interrupção de depuração. Os pontos de interrupção especificam os tamanhos de tela em que o app precisa mudar o layout.

As telas menores não têm a mesma capacidade de exibir conteúdo sem redução que as telas maiores. Para evitar que o app pareça um app para computador que foi reduzido, crie um layout separado para dispositivos móveis que use guias de divisão do conteúdo. Isso dá ao app uma experiência mais nativa em dispositivos móveis.

Os métodos de extensão abaixo, definidos no projeto MyArtist no lib/src/shared/extensions.dart (link em inglês), são um bom ponto de partida ao criar layouts otimizados para diferentes destinos.

lib/src/shared/extensions.dart (link em inglês)

extension BreakpointUtils on BoxConstraints {
  bool get isTablet => maxWidth > 730;
  bool get isDesktop => maxWidth > 1200;
  bool get isMobile => !isTablet && !isDesktop;
}

Uma tela maior que 730 pixels (na direção mais longa), mas menor que 1.200 pixels, é considerada um tablet. Qualquer dispositivo com mais de 1.200 pixels é considerado um computador. Se um dispositivo não for um tablet nem um computador, ele será considerado um dispositivo móvel. Saiba mais sobre pontos de interrupção adaptáveis em material.io. Considere usar o pacote adaptive_breakpoints (links em inglês).

O layout responsivo da tela inicial usa AdaptiveContainer e AdaptiveColumn com base na grade de 12 colunas usando os pacotes adaptive_components e adaptive_breakpoints (links em inglês) para implementar um layout de grade responsivo no Material Design.

return LayoutBuilder(
      builder: (context, constraints) {
        return Scaffold(
          body: SingleChildScrollView(
            child: AdaptiveColumn(
              children: [
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Padding(
                    padding: const EdgeInsets.symmetric(
                      horizontal: 20,
                      vertical: 40,
                    ),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        Expanded(
                          child: Text(
                            'Good morning',
                            style: context.displaySmall,
                          ),
                        ),
                        const SizedBox(width: 20),
                        const BrightnessToggle(),
                      ],
                    ),
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Column(
                    children: [
                      const HomeHighlight(),
                      LayoutBuilder(
                        builder: (context, constraints) => HomeArtists(
                          artists: artists,
                          constraints: constraints,
                        ),
                      ),
                    ],
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Padding(
                        padding: const EdgeInsets.symmetric(
                          horizontal: 15,
                          vertical: 20,
                        ),
                        child: Text(
                          'Recently played',
                          style: context.headlineSmall,
                        ),
                      ),
                      HomeRecent(
                        playlists: playlists,
                      ),
                    ],
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Padding(
                    padding: const EdgeInsets.all(15),
                    child: Row(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Flexible(
                          flex: 10,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.start,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Padding(
                                padding:
                                    const EdgeInsets.only(left: 8, bottom: 8),
                                child: Text(
                                  'Top Songs Today',
                                  style: context.titleLarge,
                                ),
                              ),
                              LayoutBuilder(
                                builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: topSongs,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                        const SizedBox(width: 25),
                        Flexible(
                          flex: 10,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.start,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Padding(
                                padding:
                                    const EdgeInsets.only(left: 8, bottom: 8),
                                child: Text(
                                  'New Releases',
                                  style: context.titleLarge,
                                ),
                              ),
                              LayoutBuilder(
                                builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: newReleases,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );

a3c16fc17be25f6c.pngUm layout adaptável precisa de dois layouts: um para dispositivos móveis e um responsivo para telas maiores. No momento, o LayoutBuilder retorna apenas um layout de computador. No lib/src/features/home/view/home_screen.dart (link em inglês), crie o layout para dispositivos móveis como uma TabBar e uma TabBarView com quatro guias.

lib/src/features/home/view/home_screen.dart (link em inglês)

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

import '../../../shared/classes/classes.dart';
import '../../../shared/extensions.dart';
import '../../../shared/providers/providers.dart';
import '../../../shared/views/views.dart';
import '../../playlists/view/playlist_songs.dart';
import 'view.dart';

class HomeScreen extends StatefulWidget {
 const HomeScreen({Key? key}) : super(key: key);

 @override
 State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
 @override
 Widget build(BuildContext context) {
   final PlaylistsProvider playlistProvider = PlaylistsProvider();
   final List<Playlist> playlists = playlistProvider.playlists;
   final Playlist topSongs = playlistProvider.topSongs;
   final Playlist newReleases = playlistProvider.newReleases;
   final ArtistsProvider artistsProvider = ArtistsProvider();
   final List<Artist> artists = artistsProvider.artists;
   return LayoutBuilder(
     builder: (context, constraints) {
       // Add from here...
       if (constraints.isMobile) {
          return DefaultTabController(
            length: 4,
            child: Scaffold(
              appBar: AppBar(
                centerTitle: false,
                title: const Text('Good morning'),
                actions: const [BrightnessToggle()],
                bottom: const TabBar(
                  isScrollable: true,
                  tabs: [
                    Tab(text: 'Home'),
                    Tab(text: 'Recently Played'),
                    Tab(text: 'New Releases'),
                    Tab(text: 'Top Songs'),
                  ],
                ),
              ),
              body: LayoutBuilder(
                builder: (context, constraints) => TabBarView(
                  children: [
                    SingleChildScrollView(
                      child: Column(
                        children: [
                          const HomeHighlight(),
                          HomeArtists(
                            artists: artists,
                            constraints: constraints,
                          ),
                        ],
                      ),
                    ),
                    HomeRecent(
                      playlists: playlists,
                      axis: Axis.vertical,
                    ),
                    PlaylistSongs(
                      playlist: topSongs,
                      constraints: constraints,
                    ),
                    PlaylistSongs(
                      playlist: newReleases,
                      constraints: constraints,
                    ),
                  ],
                ),
              ),
            ),
          );
        }
       // ... To here.

       return Scaffold(
          body: SingleChildScrollView(
            child: AdaptiveColumn(
              children: [
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Padding(
                    padding: const EdgeInsets.symmetric(
                      horizontal: 20,
                      vertical: 40,
                    ),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        Expanded(
                          child: Text(
                            'Good morning',
                            style: context.displaySmall,
                          ),
                        ),
                        const SizedBox(width: 20),
                        const BrightnessToggle(),
                      ],
                    ),
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Column(
                    children: [
                      const HomeHighlight(),
                      LayoutBuilder(
                        builder: (context, constraints) => HomeArtists(
                          artists: artists,
                          constraints: constraints,
                        ),
                      ),
                    ],
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Padding(
                        padding: const EdgeInsets.symmetric(
                          horizontal: 15,
                          vertical: 20,
                        ),
                        child: Text(
                          'Recently played',
                          style: context.headlineSmall,
                        ),
                      ),
                      HomeRecent(
                        playlists: playlists,
                      ),
                    ],
                  ),
                ),
                AdaptiveContainer(
                  columnSpan: 12,
                  child: Padding(
                    padding: const EdgeInsets.all(15),
                    child: Row(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Flexible(
                          flex: 10,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.start,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Padding(
                                padding:
                                    const EdgeInsets.only(left: 8, bottom: 8),
                                child: Text(
                                  'Top Songs Today',
                                  style: context.titleLarge,
                                ),
                              ),
                              LayoutBuilder(
                                builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: topSongs,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                        const SizedBox(width: 25),
                        Flexible(
                          flex: 10,
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.start,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Padding(
                                padding:
                                    const EdgeInsets.only(left: 8, bottom: 8),
                                child: Text(
                                  'New Releases',
                                  style: context.titleLarge,
                                ),
                              ),
                              LayoutBuilder(
                                builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: newReleases,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        );
     },
   );
 }
}

2e4115a01d76e7ae.png

Problemas?

Caso seu app não esteja sendo executado corretamente, use o código dos links abaixo para colocar tudo de volta nos eixos.

Usar espaços em branco

O espaço em branco é uma ferramenta visual importante para o app, criando um intervalo organizacional entre as seções.

É melhor ter muito espaço em branco do que pouco. É preferível adicionar mais espaços em branco do que diminuir o tamanho da fonte ou dos elementos visuais para caber mais no espaço.

A falta de espaço em branco pode ser difícil para quem tem problemas de visão. O excesso de espaços em branco pode prejudicar a coesão e deixar a interface mal organizada. Por exemplo, confira as capturas de tela abaixo:

f50d2fe899e57e42.png

cdf5a34a7658a15e.png

Em seguida, você vai adicionar um espaço em branco à tela inicial para melhorar a aparência. Depois, vai ajustar ainda mais o layout para ajustar o espaçamento.

a3c16fc17be25f6c.png Una um widget com um objeto Padding para adicionar espaços em branco ao redor dele. Aumente todos os valores de padding atuais no lib/src/features/home/view/home_screen.dart (link em inglês) para 35:

lib/src/features/home/view/home_screen.dart (link em inglês)

Scaffold(
      body: SingleChildScrollView(
        child: AdaptiveColumn(
          children: [
            AdaptiveContainer(
              columnSpan: 12,
              child: Padding(
                padding: const EdgeInsets.all(35), // Modify this line
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    Expanded(
                      child: Text(
                        'Good morning',
                        style: context.displaySmall,
                      ),
                    ),
                    const SizedBox(width: 20),
                    const BrightnessToggle(),
                  ],
                ),
              ),
            ),
            AdaptiveContainer(
              columnSpan: 12,
              child: Column(
                children: [
                  const HomeHighlight(),
                  LayoutBuilder(
                    builder: (context, constraints) => HomeArtists(
                      artists: artists,
                      constraints: constraints,
                    ),
                  ),
                ],
              ),
            ),
            AdaptiveContainer(
              columnSpan: 12,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Padding(
                    padding: const EdgeInsets.all(35), // Modify this line
                    child: Text(
                      'Recently played',
                      style: context.headlineSmall,
                    ),
                  ),
                  HomeRecent(
                    playlists: playlists,
                  ),
                ],
              ),
            ),
            AdaptiveContainer(
              columnSpan: 12,
              child: Padding(
                padding: const EdgeInsets.all(35), // Modify this line
                child: Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Flexible(
                      flex: 10,
                      child: Column(
                        mainAxisAlignment: MainAxisAlignment.start,
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Padding(
                            padding:
                                const EdgeInsets.all(35), // Modify this line
                            child: Text(
                              'Top Songs Today',
                              style: context.titleLarge,
                            ),
                          ),
                          LayoutBuilder(
                            builder: (context, constraints) => PlaylistSongs(
                              playlist: topSongs,
                              constraints: constraints,
                            ),
                          ),
                        ],
                      ),
                    ),
                    // Add spacer between tables
                    Flexible(
                      flex: 10,
                      child: Column(
                        mainAxisAlignment: MainAxisAlignment.start,
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Padding(
                            padding:
                                const EdgeInsets.all(35), // Modify this line
                            child: Text(
                              'New Releases',
                              style: context.titleLarge,
                            ),
                          ),
                          LayoutBuilder(
                            builder: (context, constraints) => PlaylistSongs(
                              playlist: newReleases,
                              constraints: constraints,
                            ),
                          ),
                        ],
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );

a3c16fc17be25f6c.png Faça uma recarga automática do app. A aparência será a mesma de antes, só que com mais espaço em branco entre os widgets. O padding extra parece melhor, mas o banner de destaque na parte de cima ainda está muito perto das bordas.

a3c16fc17be25f6c.png No lib/src/features/home/view/home_highlight.dart (link em inglês), mude o padding no banner para 35:

lib/src/features/home/view/home_highlight.dart (link em inglês)

class HomeHighlight extends StatelessWidget {
  const HomeHighlight({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Expanded(
          child: Padding(
            padding: const EdgeInsets.all(35), // Modify this line
            child: Clickable(
              child: SizedBox(
                height: 275,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(10),
                  child: Image.asset(
                    'assets/images/news/concert.jpeg',
                    fit: BoxFit.cover,
                  ),
                ),
              ),
              onTap: () => launch('https://docs.flutter.dev'),
            ),
          ),
        ),
      ],
    );
  }
}

a3c16fc17be25f6c.png Faça uma recarga automática do app. As duas playlists na parte de baixo não têm espaços em branco entre elas, então parecem pertencer à mesma tabela. Esse não é o caso, e você vai corrigir o problema em seguida.

df1d9af97d039cc8.png

a3c16fc17be25f6c.png Adicione espaços em branco entre as playlists inserindo um widget de tamanho na Row que as contém. No lib/src/features/home/view/home_screen.dart (link em inglês), adicione uma SizedBox com a largura de 35:

lib/src/features/home/view/home_screen.dart (link em inglês)

Padding(
  padding: const EdgeInsets.all(35),
  child: Row(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Flexible(
        flex: 10,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Padding(
              padding: const EdgeInsets.all(35),
              child: Text(
                'Top Songs Today',
                style: context.titleLarge,
              ),
            ),
            PlaylistSongs(
              playlist: topSongs,
              constraints: constraints,
            ),
          ],
        ),
      ),
      const SizedBox(width: 35), // Add this line
      Flexible(
        flex: 10,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Padding(
              padding: const EdgeInsets.all(35),
              child: Text(
                'New Releases',
                style: context.titleLarge,
              ),
            ),
            PlaylistSongs(
              playlist: newReleases,
              constraints: constraints,
            ),
          ],
        ),
      ),
    ],
  ),
),

a3c16fc17be25f6c.png Faça uma recarga automática do app. Ele vai ficar assim:

89411cc17daf641b.png

Agora, há espaço suficiente para o conteúdo da tela inicial, mas tudo parece muito separado e não há coesão entre as seções.

a3c16fc17be25f6c.png Até agora, você definiu todo o padding (horizontal e vertical) dos widgets na tela inicial como 35 com EdgeInsets.all(35), mas também pode definir o padding para cada uma das bordas de maneira independente. Personalize o padding para caber melhor no espaço.

  • EdgeInsets.LTRB() define individualmente o padding à esquerda, acima, à direita e abaixo.
  • EdgeInsets.symmetric() define o padding para que a vertical (de cima e de baixo) e horizontal (à esquerda e direita) sejam equivalentes.
  • EdgeInsets.only() define apenas as bordas especificadas.
Scaffold(
  body: SingleChildScrollView(
    child: AdaptiveColumn(
      children: [
        AdaptiveContainer(
           columnSpan: 12,
             child: Padding(
               padding: const EdgeInsets.fromLTRB(20, 25, 20, 10), // Modify this line
               child: Row(
                 mainAxisAlignment: MainAxisAlignment.spaceBetween,
                   children: [
                     Expanded(
                       child: Text(
                         'Good morning',
                          style: context.displaySmall,
                       ),
                     ),
                     const SizedBox(width: 20),
                     const BrightnessToggle(),
                   ],
                 ),
               ),
             ),
             AdaptiveContainer(
               columnSpan: 12,
               child: Column(
                 children: [
                   const HomeHighlight(),
                   LayoutBuilder(
                     builder: (context, constraints) => HomeArtists(
                       artists: artists,
                       constraints: constraints,
                     ),
                   ),
                 ],
               ),
             ),
             AdaptiveContainer(
               columnSpan: 12,
               child: Column(
                 crossAxisAlignment: CrossAxisAlignment.start,
                 children: [
                   Padding(
                     padding: const EdgeInsets.symmetric(
                       horizontal: 15,
                       vertical: 10,
                     ), // Modify this line
                     child: Text(
                       'Recently played',
                       style: context.headlineSmall,
                     ),
                   ),
                   HomeRecent(
                     playlists: playlists,
                   ),
                 ],
               ),
             ),
             AdaptiveContainer(
               columnSpan: 12,
               child: Padding(
                 padding: const EdgeInsets.all(15), // Modify this line
                 child: Row(
                   crossAxisAlignment: CrossAxisAlignment.start,
                   children: [
                     Flexible(
                       flex: 10,
                         child: Column(
                           mainAxisAlignment: MainAxisAlignment.start,
                           crossAxisAlignment: CrossAxisAlignment.start,
                           children: [
                             Padding(
                               padding: const EdgeInsets.only(left: 8, bottom: 8), // Modify this line
                               child: Text(
                                 'Top Songs Today',
                                 style: context.titleLarge,
                               ),
                             ),
                             LayoutBuilder(
                               builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: topSongs,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                    const SizedBox(width: 25),
                    Flexible(
                      flex: 10,
                      child: Column(
                        mainAxisAlignment: MainAxisAlignment.start,
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Padding(
                            padding: const EdgeInsets.only(left: 8, bottom: 8), // Modify this line
                            child: Text(
                              'New Releases',
                               style: context.titleLarge,
                            ),
                          ),
                          LayoutBuilder(
                            builder: (context, constraints) =>
                                    PlaylistSongs(
                                  playlist: newReleases,
                                  constraints: constraints,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        );

a3c16fc17be25f6c.png No lib/src/features/home/view/home_highlight.dart (link em inglês), defina o padding esquerdo e direito no banner como 35 e o padding de cima e de baixo como 5:

lib/src/features/home/view/home_highlight.dart (link em inglês)

class HomeHighlight extends StatelessWidget {
  const HomeHighlight({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Expanded(
          child: Padding(
            // Modify this line
            padding: const EdgeInsets.symmetric(horizontal: 35, vertical: 5),
            child: Clickable(
              child: SizedBox(
                height: 275,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(10),
                  child: Image.asset(
                    'assets/images/news/concert.jpeg',
                    fit: BoxFit.cover,
                  ),
                ),
              ),
              onTap: () => launch('https://docs.flutter.dev'),
            ),
          ),
        ),
      ],
    );
  }
}

a3c16fc17be25f6c.png Faça uma recarga automática do app. O layout e o espaçamento ficaram muito melhores. Para dar um toque final, adicione movimento e animação.

2776abfa6ca738af.png

Problemas?

Caso seu app não esteja sendo executado corretamente, use o código dos links abaixo para colocar tudo de volta nos eixos.

7. Adicionar movimento e animação

Movimentos e animações são ótimas maneiras de dar um toque dinâmico e estimulante ao app, além de fornecer feedback quando o usuário interage com o app.

Animar entre telas

O ThemeProvider define PageTransitionsTheme com animações de transição de tela para plataformas móveis (iOS, Android). Os usuários em computadores já recebem feedback do clique do mouse ou do trackpad, portanto, uma animação de transição de página não é necessária.

O Flutter oferece animações de transição de tela que podem ser configuradas pelo app com base na plataforma de destino, conforme mostrado em lib/src/shared/providers/theme.dart (link em inglês):

lib/src/shared/providers/theme.dart (link em inglês)

final pageTransitionsTheme = const PageTransitionsTheme(
  builders: <TargetPlatform, PageTransitionsBuilder>{
    TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
    TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
    TargetPlatform.linux: NoAnimationPageTransitionsBuilder(),
    TargetPlatform.macOS: NoAnimationPageTransitionsBuilder(),
    TargetPlatform.windows: NoAnimationPageTransitionsBuilder(),
  },
);

a3c16fc17be25f6c.png Transmita o PageTransitionsTheme para os temas claro e escuro em lib/src/shared/providers/theme.dart (link em inglês).

lib/src/shared/providers/theme.dart (link em inglês)

ThemeData light([Color? targetColor]) {
  final _colors = colors(Brightness.light, targetColor);
  return ThemeData.light().copyWith(
    pageTransitionsTheme: pageTransitionsTheme, // Add this line
    colorScheme: ColorScheme.fromSeed(
      seedColor: source(targetColor),
      brightness: Brightness.light,
    ),
    appBarTheme: appBarTheme(_colors),
    cardTheme: cardTheme(),
    listTileTheme: listTileTheme(),
    tabBarTheme: tabBarTheme(_colors),
    scaffoldBackgroundColor: _colors.background,
  );
}

ThemeData dark([Color? targetColor]) {
  final _colors = colors(Brightness.dark, targetColor);
  return ThemeData.dark().copyWith(
    pageTransitionsTheme: pageTransitionsTheme, // Add this line
    colorScheme: ColorScheme.fromSeed(
      seedColor: source(targetColor),
      brightness: Brightness.dark,
    ),
    appBarTheme: appBarTheme(_colors),
    cardTheme: cardTheme(),
    listTileTheme: listTileTheme(),
    tabBarTheme: tabBarTheme(_colors),
    scaffoldBackgroundColor: _colors.background,
  );
}

Sem animação no iOS

Com animação no iOS

Problemas?

Caso seu app não esteja sendo executado corretamente, use o código do link abaixo para colocar tudo de volta nos eixos.

Adicionar estados ao passar o cursor

Uma forma de adicionar movimento a um app para computador é usando estados ao passar o cursor, em que um widget muda o estado (como cor, forma ou conteúdo) quando o usuário passa o cursor sobre ele.

Por padrão, a classe _OutlinedCardState, usada para os blocos da playlist "Recently Played" (Reproduzidas recentemente), retorna uma MouseRegion, que transforma a seta do cursor em um ponteiro ao passar o cursor sobre o bloco. No entanto, você pode adicionar mais feedback visual.

a3c16fc17be25f6c.png Abra lib/src/shared/views/outlined_card.dart (link em inglês) e substitua o conteúdo pela implementação abaixo para introduzir um estado _hovered.

lib/src/shared/views/outlined_card.dart (link em inglês)

import 'package:flutter/material.dart';

class OutlinedCard extends StatefulWidget {
  const OutlinedCard({
    Key? key,
    required this.child,
    this.clickable = true,
  }) : super(key: key);
  final Widget child;
  final bool clickable;
  @override
  State<OutlinedCard> createState() => _OutlinedCardState();
}

class _OutlinedCardState extends State<OutlinedCard> {
  bool _hovered = false;

  @override
  Widget build(BuildContext context) {
    final borderRadius = BorderRadius.circular(_hovered ? 20 : 8);
    const animationCurve = Curves.easeInOut;
    return MouseRegion(
      onEnter: (_) {
        if (!widget.clickable) return;
        setState(() {
          _hovered = true;
        });
      },
      onExit: (_) {
        if (!widget.clickable) return;
        setState(() {
          _hovered = false;
        });
      },
      cursor: widget.clickable ? SystemMouseCursors.click : SystemMouseCursors.basic,
      child: AnimatedContainer(
        duration: kThemeAnimationDuration,
        curve: animationCurve,
        decoration: BoxDecoration(
          border: Border.all(
            color: Theme.of(context).colorScheme.outline,
            width: 1,
          ),
          borderRadius: borderRadius,
        ),
        foregroundDecoration: BoxDecoration(
          color: Theme.of(context).colorScheme.onSurface.withOpacity(
                _hovered ? 0.12 : 0,
              ),
          borderRadius: borderRadius,
        ),
        child: TweenAnimationBuilder<BorderRadius>(
          duration: kThemeAnimationDuration,
          curve: animationCurve,
          tween: Tween(begin: BorderRadius.zero, end: borderRadius),
          builder: (context, borderRadius, child) => ClipRRect(
            clipBehavior: Clip.antiAlias,
            borderRadius: borderRadius,
            child: child,
          ),
          child: widget.child,
        ),
      ),
    );
  }
}

a3c16fc17be25f6c.png Faça uma recarga automática do app e passe o cursor sobre um dos blocos da playlist "Reproduzidas recentemente".

61c08e46a5926e10.gif

O OutlinedCard muda a opacidade e arredonda os cantos.

a3c16fc17be25f6c.png Por fim, anime o número da música em uma playlist em um botão de reprodução usando o widget HoverableSongPlayButton definido em lib/src/shared/views/hoverable_song_play_button.dart. Em lib/src/features/playlists/view/playlist_songs.dart (links em inglês), envolva o widget Center (que contém o número da música) com um HoverableSongPlayButton:

lib/src/features/playlists/view/playlist_songs.dart (link em inglês)

HoverableSongPlayButton(        // Add this line
  hoverMode: HoverMode.overlay, // Add this line
  song: playlist.songs[index],  // Add this line
  child: Center(                // Modify this line
    child: Text(
      (index + 1).toString(),
       textAlign: TextAlign.center,
       ),
    ),
  ),                            // Add this line

a3c16fc17be25f6c.png Faça uma recarga automática do app e depois passe o cursor sobre o número da música na playlist Top Songs Today (músicas mais tocadas hoje) ou na playlist New Releases (lançamentos).

O número será animado em um botão Play (reproduzir), que tocará a música quando você clicar nele.

82587ceb5452eedf.gif

Confira o código do projeto final no GitHub (link em inglês).

8. Parabéns!

Você concluiu este codelab. Você aprendeu que pequenas mudanças podem ser integradas a um app para deixá-lo mais bonito, acessível, localizável e adequado para várias plataformas. Essas técnicas incluem, entre outras:

  • Tipografia: o texto é mais do que apenas uma ferramenta de comunicação. Use o modo como o texto é mostrado para produzir um efeito positivo na experiência dos usuários e na percepção do seu app.
  • Temas: estabeleça um sistema de design que possa ser usado de maneira confiável, sem precisar tomar decisões de design para todos os widgets.
  • Adaptabilidade: considere o dispositivo e a plataforma em que o usuário está executando o app e os recursos dele. Considere o tamanho da tela e como seu app é exibido.
  • Movimento e animação: adicionar movimento ao app agrega energia à experiência dos usuários e, na prática, fornece feedback a eles.

Com alguns pequenos ajustes, seu app pode passar de chato para incrível:

Antes

Depois

Próximas etapas

Esperamos que você tenha aprendido mais sobre a criação de apps incríveis no Flutter.

Se você usar qualquer uma das dicas ou sugestões mencionadas aqui (ou tem uma dica para compartilhar), adoraríamos saber sua opinião. Entre em contato conosco no Twitter em @rodydavis e @khanhnwin.

Os recursos abaixo também podem ser úteis (links em inglês).

Temas

Recursos adaptáveis e responsivos:

Recursos gerais de design:

Além disso, conecte-se à comunidade do Flutter (em inglês).

Vá em frente e deixe o app mais bonito!