Haz que tu app de Flutter pase de aburrida a atractiva

1. Introducción

Flutter es el kit de herramientas de IU de Google diseñado para crear aplicaciones atractivas compiladas de forma nativa que funcionen en dispositivos móviles, la Web y computadoras a partir de una base de código única. Flutter es gratuito y de código abierto; además, funciona con el código existente. Lo utilizan desarrolladores y organizaciones de todo el mundo.

En este codelab, mejorarás una aplicación musical de Flutter, que pasará de ser aburrida a atractiva. Para lograr esto, el codelab usa las herramientas y las API que se presentaron en Material 3.

Qué aprenderás

  • Cómo programar una app de Flutter que sea atractiva y se pueda usar en diferentes plataformas
  • Cómo diseñar texto en tu app para asegurarte de que contribuya a la experiencia del usuario
  • Cómo elegir los colores adecuados, personalizar widgets, crear tu propio tema y, luego, implementar rápida y fácilmente el modo oscuro
  • Cómo crear apps multiplataforma adaptables
  • Cómo crear apps que se vean bien en cualquier pantalla
  • Cómo agregar movimiento a tu app de Flutter para que realmente se destaque

Requisitos previos:

En este codelab, se presupone que tienes experiencia en Flutter. Si no es así, te recomendamos que primero aprendas los conceptos básicos. Los siguientes vínculos son útiles:

Qué crearás

En este codelab, te ayudaremos a crear la pantalla principal de una app llamada MyArtist, un reproductor de música en el que los fans pueden mantenerse al tanto de sus artistas favoritos. Se analiza cómo puedes modificar el diseño de la app para que luzca atractiva en todas las plataformas.

En los siguientes GIF animados, se muestra cómo funcionará la app cuando completes el codelab:

4a0f6509a18aaf30.gif 1557a5d9dab19d75.gif

¿Qué te gustaría aprender de este codelab?

Desconozco el tema y me gustaría obtener una buena descripción general. Tengo algunos conocimientos sobre este tema, pero me gustaría repasarlos. Estoy buscando código de ejemplo para usar en mi proyecto. Estoy buscando una explicación sobre un tema específico.

2. Configura tu entorno de Flutter

Para completar este lab, necesitas dos programas de software: el SDK de Flutter y un editor.

Puedes ejecutar el codelab con cualquiera de estos dispositivos o modalidades:

  • Un dispositivo físico Android o iOS conectado a tu computadora y configurado en el Modo de desarrollador
  • El simulador de iOS (requiere instalar las herramientas de Xcode)
  • Android Emulator (requiere configuración en Android Studio)
  • Un navegador (se requiere Chrome para la depuración)
  • Como una aplicación para computadoras que ejecuten Windows, Linux o macOS (debes desarrollarla en la plataforma donde tengas pensado realizar la implementación; por lo tanto, si quieres desarrollar una app de escritorio para Windows, debes desarrollarla en ese SO a fin de obtener acceso a la cadena de compilación correcta; encuentra detalles sobre los requisitos específicos del sistema operativo en flutter.dev/desktop)

3. Obtén la app de inicio del codelab

Clónala desde GitHub

Para clonar este codelab desde GitHub, ejecuta los siguientes comandos:

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

A fin de asegurarte de que todo funcione correctamente, ejecuta la aplicación de Flutter como una aplicación para computadoras de la manera que se muestra más adelante. De forma alternativa, abre este proyecto en tu IDE y usa sus herramientas para ejecutar la aplicación.

a3c16fc17be25f6c.png Ejecuta la app.

¡Listo! Se debería ejecutar el código de inicio correspondiente a la pantalla principal de MyArtist, por lo que deberías verla. Luce bien en computadoras, pero no así en dispositivos móviles. Por ejemplo, no toma en consideración la muesca de la pantalla. No te preocupes, ya que corregirás este problema.

9ebe486bc7dfa36b.png 1b30e16df3cde215.png

Explora el código

A continuación, explora el código.

Abre el archivo lib/src/features/home/view/home_screen.dart, que contiene lo siguiente:

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,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}

Este archivo importa material.dart e implementa un widget con estado mediante dos clases:

  • La instrucción import hace que los componentes de Material estén disponibles.
  • La clase HomeScreen representa toda la página que se muestra.
  • El método build() de la clase _HomeScreenState crea la raíz del árbol de widgets, lo que afecta cómo se crean todos los widgets de la IU.

4. Aprovecha la tipografía

El texto está en todas partes y es una forma útil de comunicarse con el usuario. ¿La app es amigable y divertida, o, tal vez, confiable y profesional? Hay una razón por la que tu app de banca favorita no usa Comic Sans. La manera en que se presenta el texto influye en la primera impresión que tiene el usuario de tu app. Estas son algunas maneras de usar el texto con más atención.

Una imagen vale más que mil palabras

Cuando sea posible, usa imágenes en vez de texto. Por ejemplo, el elemento NavigationRail de la app de inicio tiene pestañas para cada ruta principal, pero los íconos principales son idénticos:

86c5f73b3aa5fd35.png

Esto no es útil, ya que el usuario debe leer el texto de cada pestaña de todos modos. Comienza por agregar distintivos visuales para que el usuario pueda mirar rápidamente los íconos principales a fin de encontrar la pestaña deseada. Esto también ayuda con la localización y la accesibilidad.

a3c16fc17be25f6c.png En lib/src/shared/router.dart, agrega íconos principales distintos para cada destino de navegación (página principal, lista de reproducción y personas):

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

¿Tienes problemas?

Si la app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.

Elige las fuentes con atención

Las fuentes definen la personalidad de tu aplicación, de modo que es fundamental elegir la fuente correcta. A continuación, se indican algunos aspectos que debes tener en cuenta para seleccionar una fuente:

  • Sans serif o serif: Las fuentes serif tienen trazos decorativos o “remates” al final de las letras y se perciben como más formales. Las fuentes sans Serif no tienen trazos decorativos y suelen percibirse como más informales. 34bf54e4cad90101.png Una letra T mayúscula en sans serif y en serif
  • Fuentes en mayúscula: Las mayúsculas constantes son apropiadas para destacar pequeñas cantidades de texto (como los títulos), pero el uso excesivo se puede percibir como una manera de gritar, lo que puede provocar que el usuario lo pase por alto totalmente.
  • Uso de mayúsculas: Ten en cuenta este aspecto cuando agregues títulos o etiquetas. Utilizar mayúsculas en cada palabra se percibe como más formal en idiomas como el inglés, pero podría resultar inadecuado en otros. Utilizar mayúsculas solo en los sustantivos propios y en la primera palabra de cada párrafo se percibe como más informal y conversacional en inglés, y es una regla ortográfica en idiomas como el español.
  • Interletraje (espacio entre cada letra), longitud de línea (ancho del texto completo en toda la pantalla) y altura de línea (qué tan alta es cada línea de texto): Los valores muy bajos o muy altos de estas características perjudican la legibilidad de la app. Por ejemplo, es fácil perderse en un bloque de texto grande y no separado.

Con esto en mente, ve a Google Fonts y elige una fuente sans serif, como Montserrat, ya que la app de música debe ser divertida.

a3c16fc17be25f6c.png Desde la línea de comandos, extrae el paquete google_fonts. Esta acción también actualiza el archivo pubspec para agregar las fuentes como dependencia de la app.

$ flutter pub add google_fonts
<?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 En lib/src/shared/extensions.dart, importa el paquete nuevo como se muestra a continuación:

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

a3c16fc17be25f6c.png Configura TextTheme: con la fuente Montserrat

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

a3c16fc17be25f6c.png Recarga en caliente 7f9a9e103c7b5e5.png para activar los cambios (usa el botón del IDE o, desde la línea de comandos, ingresa r para hacerlo):

ff6f09f4cc39c21e.png

Deberías ver los nuevos íconos NavigationRail y texto con la fuente Montserrat.

¿Tienes problemas?

Si la app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.

5. Configura el tema

Los temas ayudan a que una app tenga un diseño estructurado y uniformidad, ya que especifican un sistema determinado de colores y estilos de texto. Los temas te permiten implementar rápidamente una IU sin tener que preocuparte por detalles menores, como especificar el color exacto de cada widget.

Los desarrolladores de Flutter suelen crear componentes de temas personalizados de dos maneras:

  • Crear widgets personalizados individuales, cada uno con su propio tema
  • Crear temas con alcance para widgets predeterminados

En este ejemplo, se usa un proveedor de temas ubicado en lib/src/shared/providers/theme.dart para crear widgets y colores con un tema coherente en la app:

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.png Para usar el proveedor, crea una instancia y pásala al objeto de tema específico en MaterialApp, ubicado en lib/src/shared/app.dart. Lo heredará cualquier objeto Theme anidado:

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,
                 );
               },
             ),
           )),
     ),
   );
 }
}

Ahora que el tema está configurado, elige colores para la aplicación.

Elegir el conjunto de colores adecuado no siempre es fácil. Quizás tengas una idea del color principal, pero es probable que quieras que la app tenga más de uno. ¿Qué colores deben tener el texto, el título, el contenido y los vínculos? ¿Qué ocurre con el color del fondo? Material Theme Builder es una herramienta basada en la Web (lanzada en Material 3) que te ayuda a seleccionar un conjunto de colores complementarios para tu app.

a3c16fc17be25f6c.png A fin de elegir un color de origen para la aplicación, abre Material Theme Builder y explora los diferentes colores de la IU. Es importante que selecciones un color que se adapte a la estética de la marca o tus preferencias personales.

Después de crear un tema, haz clic con el botón derecho en el cuadro de color Primary. Se abrirá un cuadro de diálogo con el valor hexadecimal del color principal. Copia este valor. También puedes establecer el color mediante este cuadro de diálogo.

a6201933c4be275c.gif

a3c16fc17be25f6c.png Pasa el valor hexadecimal del color principal al proveedor de temas. Por ejemplo, el color hexadecimal #00cbe6 se especifica como Color(0xff00cbe6). El ThemeProvider genera un objeto ThemeData que contiene el conjunto de colores complementarios de los que obtuviste una vista previa en Material Theme Builder:

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

Reinicia la app en caliente. Una vez que se aplique el color primario, la app comienza a parecer más expresiva. Para acceder a todos los colores nuevos, haz referencia al tema en el contexto y toma el ColorScheme:

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

a3c16fc17be25f6c.png Para usar un color en particular, accede a un rol de color en colorScheme. Ve a lib/src/shared/views/outlined_card.dart y asígnale un borde a OutlinedCard:

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.
      ),
    );
  }
}

Material 3 presenta roles de color matizados que se complementan y pueden usarse en toda la IU para agregar nuevas capas de expresión. Entre estos nuevos roles de color, se incluyen los siguientes:

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

Además, los nuevos tokens de diseño admiten los temas claro y oscuro:

7b51703ed96196a4.png

Estos roles de color se pueden usar para asignar significado y énfasis a diferentes partes de la IU. Incluso si un componente no se destaca, puedes aprovechar el color dinámico.

a3c16fc17be25f6c.png El usuario puede establecer el brillo de la app en la configuración del sistema del dispositivo. En lib/src/shared/app.dart, cuando el dispositivo esté en modo oscuro, muestra un tema oscuro y el modo de tema a la MaterialApp.

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,
);

Haz clic en el ícono de luna ubicado en la esquina superior derecha para habilitar el modo oscuro.

60ad6e64df0c5957.gif

¿Tienes problemas?

Si la app no se ejecuta de manera correcta, usa el código del siguiente vínculo para solucionar el problema.

6. Agrega un diseño adaptable

Con Flutter, puedes crear apps que se ejecuten casi en cualquier plataforma, pero eso no quiere decir que se espera que se comporten igual en todas ellas. Los usuarios esperan diferentes comportamientos y funciones en distintas plataformas.

Material ofrece paquetes de Flutter para facilitar el trabajo con diseños adaptables. Puedes encontrarlos en GitHub.

Ten en cuenta las siguientes diferencias de plataforma cuando compiles una aplicación adaptable y multiplataforma:

  • Método de entrada: Mouse, panel táctil o control de juegos
  • Tamaño de fuente, orientación del dispositivo y distancia de visualización
  • Tamaño de pantalla y factor de forma: Teléfono, tablet, dispositivo plegable, computadora o Web

a3c16fc17be25f6c.png El archivo lib/src/shared/views/adaptive_navigation.dart contiene una clase de navegación en la que puedes proporcionar una lista de destinos y contenido para renderizar el cuerpo. Como se usa este diseño en varias pantallas, hay un diseño de base compartido para pasar a cada diseño secundario. Los rieles de navegación son ideales para computadoras y pantallas grandes, pero, en su lugar, muestran una barra de navegación inferior en dispositivos móviles para optimizar el diseño.

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

No todas las pantallas tienen el mismo tamaño. Si intentas mostrar la versión para computadoras de tu app en un teléfono, tendrías que forzar la vista y hacer zoom para ver todo. Quieres que tu app cambie de aspecto en función de la pantalla donde se muestra. El diseño responsivo te permite garantizar que tu app se vea genial en pantallas de todos los tamaños.

Para que tu app sea responsiva, agrega algunos puntos de interrupción adaptables (que no se deben confundir con los de depuración). Estos puntos de interrupción especifican los tamaños de pantalla en los que tu app debería cambiar su diseño.

Las pantallas más pequeñas no pueden mostrar tanto contenido como las más grandes sin reducir el tamaño del contenido. A fin de evitar que tu app parezca una de escritorio con tamaño reducido, crea un diseño independiente para dispositivos móviles que use pestañas a fin de desglosar el contenido. De esta manera, la app tiene un aspecto más nativo en dispositivos móviles.

Los siguientes métodos de extensión (definidos en el proyecto MyArtist de lib/src/shared/extensions.dart) son un buen punto de partida si deseas crear diseños optimizados para diferentes destinos.

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

Una pantalla de más de 730 píxeles (en el lado más largo), pero menos de 1,200 píxeles, se considera una tablet. Cualquier tamaño superior a 1,200 píxeles se considera una computadora. Si un dispositivo no es una tablet ni una computadora de escritorio, se considerará un dispositivo móvil. Puedes obtener más información sobre los puntos de interrupción adaptables en material.io. Te recomendamos usar el paquete adaptive_breakpoints.

El diseño responsivo de la pantalla principal usa AdaptiveContainer y AdaptiveColumn basados en la cuadrícula de 12 columnas mediante los paquetes adaptive_components y adaptive_breakpoints para implementar un diseño de cuadrícula responsivo en 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.png Un diseño adaptable necesita dos diseños: uno para dispositivos móviles y otro responsivo para pantallas más grandes. Actualmente, LayoutBuilder solo muestra un diseño para computadoras. En lib/src/features/home/view/home_screen.dart, crea el diseño para dispositivos móviles como un elemento TabBar y TabBarView con 4 pestañas.

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

¿Tienes problemas?

Si la app no se ejecuta de manera correcta, usa el código del siguiente vínculo para solucionar el problema.

Usa espacio en blanco

El espacio en blanco es una herramienta visual importante para tu app que crea una interrupción organizacional entre secciones.

Es mejor tener demasiado espacio en blanco que no tener suficiente. Es preferible agregar más espacio en blanco que reducir el tamaño de la fuente o los elementos visuales para que quepan más.

La falta de espacio en blanco puede ser difícil para quienes tienen problemas de visión. Una cantidad excesiva de espacio en blanco puede demostrar falta de coherencia y hacer que la IU luzca mal organizada. Por ejemplo, mira las siguientes capturas de pantalla:

f50d2fe899e57e42.png

cdf5a34a7658a15e.png

A continuación, agregarás espacio en blanco a la pantalla principal. Luego, ajustarás el diseño aún más para ajustar el espacio.

a3c16fc17be25f6c.png Une un widget con un objeto Padding para agregar espacios en blanco alrededor de ese widget. Aumenta todos los valores de relleno que se encuentran actualmente en lib/src/features/home/view/home_screen.dart a 35:

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 Recarga la app en caliente. Debería verse igual que antes, pero con más espacio en blanco entre los widgets. El relleno adicional se ve mejor, pero el banner de resaltado en la parte superior todavía está demasiado cerca de los bordes.

a3c16fc17be25f6c.png En lib/src/features/home/view/home_highlight.dart, cambia el relleno del banner a 35:

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 Recarga la app en caliente. Las dos listas de reproducción de la parte inferior no tienen espacio en blanco entre ellas, por lo que parecen pertenecer a la misma tabla. No es el caso, así que lo solucionarás a continuación.

df1d9af97d039cc8.png

a3c16fc17be25f6c.png Agrega espacio en blanco entre las listas de reproducción insertando un widget de tamaño en el elemento Row que las contiene. En lib/src/features/home/view/home_screen.dart, agrega un elemento SizedBox con un ancho de 35:

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 Recarga la app en caliente. Debería verse de la siguiente manera:

89411cc17daf641b.png

Ahora hay suficiente espacio para el contenido de la pantalla principal, pero todo está demasiado separado y no hay cohesión entre las secciones.

a3c16fc17be25f6c.png Hasta ahora, configuraste todos los rellenos (horizontales y verticales) de los widgets en la pantalla principal en 35 con EdgeInsets.all(35), pero también puedes configurar el relleno para cada uno de los bordes. Personaliza el relleno para que se adapte mejor al espacio.

  • EdgeInsets.LTRB() se configura de forma individual a la izquierda, derecha y arriba y abajo.
  • EdgeInsets.symmetric() establece que el relleno vertical (partes inferior y superior) sea equivalente y que el horizontal (izquierda y derecha) también lo sea.
  • EdgeInsets.only() establece solo los bordes especificados.
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 En lib/src/features/home/view/home_highlight.dart, establece el relleno izquierdo y derecho del banner en 35, y los rellenos en las partes inferior y superior en 5:

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 Recarga la app en caliente. El diseño y el espacio se ven mucho mejor. Como toque final, agrega movimiento y animación.

2776abfa6ca738af.png

¿Tienes problemas?

Si la app no se ejecuta de manera correcta, usa el código del siguiente vínculo para solucionar el problema.

7. Agrega movimiento y animación

El movimiento y la animación son formas excelentes de agregar desplazamiento y energía, y de proporcionar una respuesta cuando el usuario interactúa con la app.

Agrega animaciones entre pantallas

El ThemeProvider define un PageTransitionsTheme con animaciones de transición de pantalla para plataformas móviles (iOS y Android). Los usuarios de computadoras ya reciben respuestas del clic del mouse o del panel táctil, por lo que no se necesita una animación de transición de página.

Flutter brinda las animaciones de transición de pantalla que puedes configurar para tu app en función de la plataforma de destino, como se muestra en lib/src/shared/providers/theme.dart:

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

a3c16fc17be25f6c.png Pasa PageTransitionsTheme a los temas claro y oscuro en lib/src/shared/providers/theme.dart.

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,
  );
}

Sin animación en iOS

Con animación en iOS

¿Tienes problemas?

Si la app no se ejecuta de manera correcta, usa el código del siguiente vínculo para solucionar el problema.

Agrega estados cuando se coloca el cursor sobre un elemento

Una forma de agregar movimiento a una app de escritorio es con los estados de desplazamiento, donde un widget cambia su estado (como el color, la forma o el contenido) cuando el usuario coloca el cursor sobre él.

De forma predeterminada, la clase _OutlinedCardState (que se usa para los mosaicos de listas de reproducción “Recently played”) muestra un elemento MouseRegion, que convierte la flecha del cursor en una mano cuando se coloca el cursor sobre un elemento, pero puedes agregar más respuestas visuales.

a3c16fc17be25f6c.png Abre lib/src/shared/views/outlined_card.dart y reemplaza su contenido por la siguiente implementación para agregar un estado _hovered.

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 Recarga la app en caliente y, luego, coloca el cursor sobre uno de los mosaicos de la lista de reproducción que se reprodujeron recientemente.

61c08e46a5926e10.gif

OutlinedCard cambia la opacidad y redondea las esquinas.

a3c16fc17be25f6c.png Por último, anima el número de canción de una lista de reproducción en un botón de reproducción con el widget HoverableSongPlayButton definido en lib/src/shared/views/hoverable_song_play_button.dart. En lib/src/features/playlists/view/playlist_songs.dart, une el widget Center (que contiene el número de canción) con un HoverableSongPlayButton:

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 Recarga la app en caliente y coloca el cursor sobre el número de la canción en las listas de reproducción Top Songs Today o New Releases.

El número comienza con un botón de reproducción que reproduce la canción cuando haces clic en ella.

82587ceb5452eedf.gif

Consulta el código final del proyecto en GitHub.

8. ¡Felicitaciones!

Completaste este codelab. Aprendiste que hay muchos cambios pequeños que puedes integrar en una app a fin de hacerla más atractiva, accesible, localizable y adecuada para múltiples plataformas. Estas técnicas incluyen, entre otras, las siguientes:

  • Tipografía: El texto es más que una simple herramienta de comunicación. Usa la manera en que se muestra el texto para generar un efecto positivo en la experiencia de los usuarios y la percepción de tu app.
  • Temas: Establece un sistema de diseño que puedas usar de manera confiable sin tener que tomar decisiones de diseño para cada widget.
  • Adaptabilidad: Considera el dispositivo y la plataforma en los que se ejecuta tu app y sus funciones. Ten en cuenta el tamaño de la pantalla y cómo se muestra tu app.
  • Movimiento y animación: Cuando agregas movimiento a tu app, se agrega energía a la experiencia del usuario y, con mayor práctica, se proporcionan comentarios para los usuarios.

Con algunos pequeños ajustes, tu app pasará de ser aburrida a atractiva:

Antes

Después

Próximos pasos

Esperamos que hayas aprendido más sobre cómo crear apps atractivas en Flutter.

Si aplicas algunas de las sugerencias o trucos que se mencionan aquí (o tienes una sugerencia propia para compartir), nos encantaría conocer tu opinión. Comunícate con nosotros en Twitter mediante los identificadores @rodydavis y @khanhnwin.

Los siguientes recursos también pueden resultarte útiles.

Temas

Recursos adaptables y responsivos:

Recursos generales de diseño:

Además, puedes conectarte con la comunidad de Flutter.

¡Sigue adelante y mejora el atractivo del ecosistema de apps!