지루한 Flutter 앱을 멋지게 바꿔보세요

1. 소개

Flutter는 하나의 코드베이스를 사용해 모바일, 웹, 데스크톱을 대상으로 아름다운 네이티브 컴파일 애플리케이션을 개발하기 위한 Google의 UI 도구 모음입니다. Flutter는 기존 코드와 호환되며 전 세계의 개발자와 조직에서 사용하고 있고, 무료 및 오픈소스로 제공됩니다.

이 Codelab에서는 Flutter 음악 애플리케이션을 지루하지 않고 멋지게 개선합니다. 이를 위해 Codelab에서는 머티리얼 3에 도입된 도구와 API를 사용합니다.

학습할 내용

  • 여러 플랫폼에서 유용하고 멋진 Flutter 앱을 작성하는 방법
  • 사용자 경험에 추가할 수 있도록 앱에 텍스트를 디자인하는 방법
  • 적절한 색상을 선택하고, 위젯을 맞춤설정하고, 나만의 테마를 만들고, 빠르고 쉽게 어두운 모드를 구현하는 방법
  • 크로스 플랫폼 적응형 앱을 빌드하는 방법
  • 어떤 화면에서도 잘 보이는 앱을 빌드하는 방법
  • Flutter 앱에 움직임을 추가하여 돋보이게 하는 방법

기본 요건:

이 Codelab에서는 Flutter를 사용한 경험이 있다고 가정합니다. 그렇지 않은 경우 먼저 기본사항을 학습하는 것이 좋습니다. 다음 링크가 도움이 됩니다.

빌드할 항목

이 Codelab에서는 팬들이 좋아하는 아티스트의 최신 음악을 들을 수 있는 음악 플레이어 앱인 MyArtist라는 애플리케이션의 홈 화면을 빌드하는 방법을 안내합니다. 여러 플랫폼에서 앱 디자인을 아름답게 꾸밀 수 있는 방법을 다룹니다.

다음 애니메이션 GIF는 이 Codelab에서 구현을 완료하면 앱이 어떻게 작동하는지 보여줍니다.

4a0f6509a18aaf30.gif 1557a5d9dab19d75.gif

이 Codelab에서 배우고 싶은 내용은 무엇인가요?

주제를 처음 접하기 때문에 간단하게 내용을 파악하고 싶습니다. 이 주제에 관해 약간 알고 있지만 한 번 더 확인하고 싶습니다. 프로젝트에 사용할 코드 예시를 찾고 있습니다. 구체적인 항목에 관한 설명을 찾고 있습니다.

2. Flutter 환경 설정

이 실습을 완료하려면 Flutter SDK편집기라는 두 가지 소프트웨어가 필요합니다.

다음 기기 중 하나를 사용하여 이 Codelab을 실행할 수 있습니다.

  • 컴퓨터에 연결되어 있으며 개발자 모드로 설정된 실제 Android 또는 iOS 기기
  • iOS 시뮬레이터(Xcode 도구 설치 필요)
  • Android Emulator(Android 스튜디오 설정 필요)
  • 브라우저(디버깅 시 Chrome 필요)
  • Windows, Linux 또는 macOS 데스크톱 애플리케이션. 배포에 사용할 플랫폼에서 개발해야 합니다. 따라서 Windows 데스크톱 앱을 개발하려면 적절한 빌드 체인에 액세스할 수 있도록 Windows에서 개발해야 합니다. flutter.dev/desktop에 운영체제별 요구사항이 자세히 설명되어 있습니다.

3. Codelab 시작 앱 다운로드

GitHub에서 클론

이 Codelab을 GitHub에서 클론하려면 다음 명령어를 실행하세요.

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

모든 것이 제대로 작동하는지 확인하려면 아래와 같이 Flutter 애플리케이션을 데스크톱 애플리케이션으로 실행합니다. 또는 IDE에서 이 프로젝트를 열고 IDE 도구를 사용하여 애플리케이션을 실행합니다.

a3c16fc17be25f6c.png 앱 실행

완료되었습니다. MyArtist 홈 화면의 시작 코드가 실행됩니다. MyArtist 홈 화면이 표시됩니다. 데스크톱에서는 잘 보이지만 모바일에서는... 그렇지 않네요. 우선 한 가지 이유는 노치를 따르지 않아서입니다. 이 문제는 해결할 수 있으니 걱정하지 마세요.

9ebe486bc7dfa36b.png 1b30e16df3cde215.png

코드 둘러보기

이제 코드를 살펴보겠습니다.

다음 내용이 포함된 lib/src/features/home/view/home_screen.dart를 엽니다.

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

이 파일은 material.dart를 가져오고 다음 두 가지 클래스를 사용하여 스테이트풀(Stateful) 위젯을 구현합니다.

  • import 문을 사용하면 머티리얼 구성요소를 사용할 수 있습니다.
  • HomeScreen 클래스는 표시되는 전체 페이지를 나타냅니다.
  • _HomeScreenState 클래스의 build() 메서드는 위젯 트리의 루트를 만듭니다. 이는 UI의 모든 위젯이 생성되는 방식에 영향을 줍니다.

4. 서체 활용

텍스트는 어디에나 있습니다. 텍스트는 사용자와 소통할 때 유용한 방법입니다. 친절하고 재미있는 앱인가요? 아니면 신뢰할 수 있고 전문적인 앱인가요? 즐겨 사용하는 뱅킹 앱에서 Comic Sans를 사용하지 않는 것은 이유가 있습니다. 텍스트가 표시되는 방식은 앱에 대한 사용자의 첫인상을 결정합니다. 다음은 텍스트를 보다 신중하게 사용할 수 있는 몇 가지 방법입니다.

말하지 말고 보여주세요

가능하면 '말하기' 보다 '보여주는' 방식을 사용합니다. 예를 들어, 시작 앱의 NavigationRail에는 각 기본 경로에 대한 탭이 있지만 연결되는 아이콘은 동일합니다.

86c5f73b3aa5fd35.png

이 방법은 사용자가 각 탭의 텍스트를 계속 읽어야 하므로 유용하지 않습니다. 먼저 시각적 신호를 추가하여 사용자가 원하는 탭을 찾기 위해 연결되는 아이콘을 빠르게 볼 수 있도록 합니다. 이는 현지화 및 접근성에도 도움이 됩니다.

a3c16fc17be25f6c.png lib/src/shared/router.dart에서 각 탐색 대상(홈, 재생목록, 사람)에 따라 눈에 띄는 연결되는 아이콘을 추가합니다.

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

문제가 있나요?

앱이 올바르게 실행되지 않는다면 오타가 있는지 확인합니다. 필요한 경우 다음 링크의 코드를 사용하면 제대로 작동합니다.

신중하게 글꼴 선택

글꼴은 애플리케이션의 성격을 설정하므로 올바른 글꼴을 선택하는 것이 중요합니다. 글꼴을 선택할 때 고려해야 할 사항은 다음과 같습니다.

  • Sans Serif 또는 Serif: Serif 글꼴은 문자의 끝에 '꼬리' 같은 획이 포함되어 있어 보다 격식을 차린 것으로 인식됩니다. Sans Serif 글꼴은 화려하게 획이 칠해지지 않으며 좀 더 친근한 방식으로 인식되는 경향이 있습니다. 34bf54e4cad90101.png Sans Serif 대문자 T 및 Serif 대문자 T
  • 전체 대문자 글꼴: 전체에 대문자를 사용하는 것은 적은 양의 텍스트(예: 헤드라인)에 주의를 환기시키는 데 적합하지만 과도하게 사용하면 소리를 지르는 것으로 인식되어 사용자가 완전히 무시할 수 있습니다.
  • 타이틀 케이스 또는 센텐스 케이스: 제목 또는 라벨을 추가할 때 대문자 사용 방법을 고려하세요. 각 단어의 첫 글자가 대문자인 타이틀 케이스('This Is a Title Case Title')가 더 격식이 있는 느낌입니다. 센텐스 케이스는 고유명사와 텍스트의 첫 단어만 대문자로 표기하며('This is a sentence case title') 보다 대화식의 친근한 표현입니다.
  • 커닝(각 문자 사이의 간격), 줄 길이(화면 전체에 걸친 전체 텍스트 너비), 줄 높이(각 텍스트 줄의 높이): 이 중 하나라도 너무 많거나 너무 적으면 앱 가독성이 떨어집니다. 예를 들어, 크고 나뉘어 지지 않은 텍스트 단위를 읽으면 읽는 부분을 놓치기 쉽습니다.

이 점을 염두에 두고 Google Fonts로 이동하여 Montserrat와 같은 Sans Serif 글꼴을 선택하세요. 음악 앱은 재미있고 신나야 하니까요.

a3c16fc17be25f6c.png 명령줄에서 google_fonts 패키지를 가져옵니다. 이렇게 하면 pubspec 파일도 업데이트되어 글꼴을 앱 종속 항목으로 추가할 수 있습니다.

$ 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 lib/src/shared/extensions.dart에서, 다음과 같이 새 패키지를 가져옵니다.

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

a3c16fc17be25f6c.png Montserrat TextTheme: 설정

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

a3c16fc17be25f6c.png 핫 리로드 7f9a9e103c7b5e5.png를 사용하여 변경사항을 활성화합니다. IDE에서 버튼을 사용하거나 명령줄에서 r을 입력하여 핫 리로드합니다.

ff6f09f4cc39c21e.png

새로운 NavigationRail 아이콘이 Montserrat 글꼴에 표시된 텍스트와 함께 표시됩니다.

문제가 있나요?

앱이 올바르게 실행되지 않는다면 오타가 있는지 확인합니다. 필요한 경우 다음 링크의 코드를 사용하면 제대로 작동합니다.

5. 테마 설정

테마를 사용하면 색상과 텍스트 스타일의 집합 시스템을 지정하여 앱에 구조화된 디자인과 통일성을 적용할 수 있습니다. 테마를 사용하면 모든 단일 위젯에 정확한 색상을 지정하는 등 사소한 세부정보에 스트레스를 받지 않고 UI를 빠르게 구현할 수 있습니다.

Flutter 개발자는 일반적으로 다음 두 가지 방법 중 하나로 커스텀 테마 구성요소를 생성합니다.

  • 각각 고유한 테마가 있는 개별 커스텀 위젯을 만듭니다.
  • 기본 위젯의 범위가 지정된 테마를 만듭니다.

이 예에서는 lib/src/shared/providers/theme.dart에 있는 테마 제공업체를 사용하여 앱 전체에서 일관된 테마의 위젯과 색상을 만듭니다.

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제공업체를 사용하려면 인스턴스를 만들어 lib/src/shared/app.dart에 있는 MaterialApp의 범위가 지정된 테마 객체에 전달합니다. 중첩된 Theme 객체가 상속됩니다.

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

이제 테마가 설정되었으므로 애플리케이션의 색상을 선택합니다.

원하는 색상 세트를 선택하는 것이 어려울 수도 있습니다. 기본 색상에 대한 아이디어가 있지만 앱의 색상이 2개 이상이면 어떨지 생각해 볼 수 있습니다. 텍스트가 어떤 색상이어야 할까요? 제목은? 콘텐츠는? 링크는? 배경색은 어떨까요? 머티리얼 테마 빌더는 머티리얼 3에서 도입된 웹 기반 도구로, 앱의 보완적인 색상 세트를 선택하는 데 도움이 됩니다.

a3c16fc17be25f6c.png애플리케이션의 소스 색상을 선택하려면 머티리얼 테마 빌더를 열고 UI의 다양한 색상을 살펴봅니다. 브랜드의 미적 요소 및 개인적 취향에 맞는 색상을 선택하는 것이 중요합니다.

테마를 생성한 후 기본 색상 풍선을 마우스 오른쪽 버튼으로 클릭하면 기본 색상의 16진수 값을 포함하는 대화상자가 열립니다. 이 값을 복사합니다. (이 대화상자를 사용해 색상을 설정할 수도 있습니다.)

a6201933c4be275c.gif

a3c16fc17be25f6c.png기본 색상의 16진수 값을 테마 제공업체에 전달합니다. 예를 들어 16진수 색상 #00cbe6Color(0xff00cbe6)로 지정됩니다. ThemeProvider는 머티리얼 테마 빌더에서 미리 본 보완 색상 세트를 포함하는 ThemeData를 생성합니다.

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

앱을 핫 리스타트합니다. 기본 색상이 지정되면 앱이 더 풍부해지기 시작합니다. 컨텍스트에서 테마를 참조하고 ColorScheme를 선택하여 모든 새 색상에 액세스합니다.

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

a3c16fc17be25f6c.png특정 색상을 사용하려면 colorScheme색상 역할에 액세스합니다. lib/src/shared/views/outlined_card.dart로 이동하여 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.
      ),
    );
  }
}

머티리얼 3은 서로를 보완하는 미묘한 색상 역할을 도입하며 UI 전체에 이를 사용하여 표현식의 새 레이어를 추가합니다. 새로운 색상 역할은 다음과 같습니다.

  • 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

또한 새로운 디자인 토큰은 밝은 테마와 어두운 테마를 모두 지원합니다.

7b51703ed96196a4.png

이러한 색상 역할을 사용하여 UI의 다양한 부분에 의미를 부여하고 강조할 수 있습니다. 구성요소가 눈에 띄지 않더라도 동적 색상을 활용할 수 있습니다.

a3c16fc17be25f6c.png 사용자가 기기의 시스템 설정에서 앱 밝기를 설정할 수 있습니다. lib/src/shared/app.dart에서 기기가 어두운 모드로 설정되면 어두운 테마와 테마 모드를 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,
);

오른쪽 상단에 있는 달 아이콘을 클릭하여 어두운 모드를 사용 설정합니다.

60ad6e64df0c5957.gif

문제가 있나요?

앱이 올바르게 실행되지 않는 경우 다음 링크의 코드를 사용하면 제대로 작동할 수 있습니다.

6. 적응형 디자인 추가

Flutter를 사용하면 거의 모든 곳에서 실행되는 앱을 빌드할 수 있지만, 모든 앱이 모두 똑같이 작동해야 하는 것은 아닙니다. 사용자는 다양한 플랫폼에서 다양한 동작과 기능을 기대하고 있습니다.

머티리얼은 적응형 레이아웃으로 더 쉽게 사용할 수 있는 패키지를 제공합니다. Flutter 패키지는 GitHub에서 찾을 수 있습니다.

크로스 플랫폼 적응형 애플리케이션을 빌드할 때 다음의 플랫폼 차이점에 유의하세요.

  • 입력 방법: 마우스, 터치 또는 게임패드
  • 글꼴 크기, 기기 방향 및 시청 거리
  • 화면 크기 및 폼 팩터: 휴대전화, 태블릿, 폴더블, 데스크톱, 웹

a3c16fc17be25f6c.png lib/src/shared/views/adaptive_navigation.dart 파일에는 본문을 렌더링할 대상 및 콘텐츠 목록을 제공할 수 있는 탐색 클래스가 포함되어 있습니다. 이 레이아웃을 여러 화면에서 사용하므로 각 하위 요소에 전달할 공유된 기본 레이아웃이 있습니다. 탐색 레일은 데스크톱 및 대형 화면에 적합하지만, 대신 모바일에 하단 탐색 메뉴를 표시하여 레이아웃을 모바일 친화적으로 만듭니다.

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

모든 화면의 크기가 같은 것은 아닙니다. 휴대전화에 데스크톱 버전의 앱을 표시하고자 하는 경우, 눈을 가늘게 뜨거나 확대/축소를 통해 모든 것을 봐야 했습니다. 앱이 표시되는 화면에 따라 앱 디자인을 변경하는 것이 좋습니다. 반응형 디자인을 사용하면 모든 크기의 화면에서 앱이 멋지게 보이도록 할 수 있습니다.

앱을 반응형으로 만들려면 디버깅 중단점과 혼동하지 않도록 몇 가지 적응형 중단점을 도입하세요. 이러한 중단점은 앱에서 레이아웃을 변경해야 하는 화면 크기를 지정합니다.

작은 화면은 콘텐츠를 축소하지 않고 큰 화면만큼 표시할 수 없습니다. 앱이 축소된 데스크톱 앱처럼 보이지 않도록 하려면 탭을 사용하여 콘텐츠를 분할하는 별도의 모바일 레이아웃을 만드세요. 따라서 모바일에서 앱에 더 자연스러운 느낌을 줄 수 있습니다.

lib/src/shared/extensions.dart의 MyArtist 프로젝트에 정의된 다음 확장 프로그램 메서드는 다양한 타겟에 최적화된 레이아웃을 설계할 때 좋은 출발점입니다.

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

730픽셀(가장 긴 방향 기준) 보다 크고 1,200픽셀보다 작은 화면은 태블릿으로 간주됩니다. 1200픽셀보다 큰 화면은 데스크톱으로 간주됩니다. 태블릿이나 데스크톱 기기가 아닌 기기는 모바일로 간주됩니다. material.io에서 적응형 중단점에 대해 자세히 알아보세요. Adaptive_breakpoints 패키지를 사용하는 것이 좋습니다.

홈 화면의 반응형 레이아웃에서는 adaptive_componentsadaptive_breakpoints 패키지를 사용하는 12개의 열 그리드에 기반한 AdaptiveContainerAdaptiveColumn을 사용하여 머티리얼 디자인에서 반응형 그리드 레이아웃을 구현합니다.

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적응형 레이아웃에는 모바일용 레이아웃과 큰 화면의 반응형 레이아웃이라는 두 가지 레이아웃이 필요합니다. LayoutBuilder는 현재 데스크톱 레이아웃만 반환합니다. lib/src/features/home/view/home_screen.dart에서 모바일 레이아웃을 4개의 탭이 있는 TabBarTabBarView로 빌드합니다.

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

문제가 있나요?

앱이 올바르게 실행되지 않는 경우 다음 링크의 코드를 사용하면 제대로 작동할 수 있습니다.

공백 사용

공백은 앱에 중요한 시각적 도구로, 섹션 간 구조적인 구분을 만듭니다.

공백은 충분하지 않은 것 보다 너무 많은 것이 더 낫습니다. 공간에 맞추기 위해 글꼴 또는 시각적 요소의 크기를 줄이는 것보다 공백을 추가하는 것이 좋습니다.

공백이 부족하면 시력에 문제가 있는 사람들이 어려움을 겪을 수 있습니다. 공백이 너무 많으면 응집력이 낮아지고 UI가 제대로 구성되지 않을 수 있습니다. 예를 들자면 다음 스크린샷을 참조하세요.

f50d2fe899e57e42.png

cdf5a34a7658a15e.png

그런 다음 홈 화면에 공백을 추가하여 공간을 더 확보하게 됩니다. 그런 다음 레이아웃을 추가로 조정하여 간격을 세밀하게 조정합니다.

a3c16fc17be25f6c.png Padding 객체로 위젯을 래핑하여 위젯 주위에 공백을 추가합니다. 현재 lib/src/features/home/view/home_screen.dart에 있는 모든 패딩 값을 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 앱을 핫 리로드합니다. 이전과 동일하게 표시되지만 위젯 사이에는 더 많은 공백이 있습니다. 추가 패딩은 더 멋져 보이지만 상단의 하이라이트 배너가 여전히 가장자리에 너무 가까이 있습니다.

a3c16fc17be25f6c.png lib/src/features/home/view/home_highlight.dart에서 배너의 패딩을 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 앱을 핫 리로드합니다. 하단의 두 재생목록 사이에는 공백이 없어 동일한 테이블에 속한 것처럼 보입니다. 그렇지 않은 경우 다음 단계에서 수정합니다.

df1d9af97d039cc8.png

a3c16fc17be25f6c.png 재생목록이 포함된 Row에 크기 위젯을 삽입하여 재생목록 사이에 공백을 추가합니다. lib/src/features/home/view/home_screen.dart에서 너비가 35인 SizedBox를 추가합니다.

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 앱을 핫 리로드합니다. 앱은 다음과 같이 표시됩니다.

89411cc17daf641b.png

이제 홈 화면 콘텐츠를 위한 공간이 충분하지만 모든 항목이 너무 구분되어 섹션 간에 응집이 없습니다.

a3c16fc17be25f6c.png 지금까지 EdgeInsets.all(35)를 사용하여 홈 화면의 위젯에 관한 모든 패딩(가로와 세로 모두)을 35로 설정했지만 각 가장자리에 독립적으로 패딩을 설정할 수도 있습니다. 공간에 맞게 패딩을 맞춤설정합니다.

  • EdgeInsets.LTRB()는 개별적으로 왼쪽, 상단, 오른쪽, 하단을 설정합니다.
  • EdgeInsets.symmetric()은 세로(상단 및 하단)의 패딩이 동일하고 가로(왼쪽 및 오른쪽)의 패딩이 동일하도록 설정합니다.
  • EdgeInsets.only()는 지정된 가장자리만 설정합니다.
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 lib/src/features/home/view/home_highlight.dart에서 배너의 왼쪽 및 오른쪽 패딩을 35로, 상단 및 하단 패딩을 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 앱을 핫 리로드합니다. 레이아웃과 간격이 훨씬 더 보기 좋습니다. 마무리를 위해 모션 및 애니메이션을 추가합니다.

2776abfa6ca738af.png

문제가 있나요?

앱이 올바르게 실행되지 않는 경우 다음 링크의 코드를 사용하면 제대로 작동할 수 있습니다.

7. 모션 및 애니메이션 추가

모션과 애니메이션은 움직임과 에너지를 소개하고 사용자가 앱과 상호작용할 때 피드백을 제공하는 좋은 방법입니다.

화면 간 애니메이션

ThemeProvider는 모바일 플랫폼(iOS, Android)용 화면 전환 애니메이션으로 PageTransitionsTheme를 정의합니다. 데스크톱 사용자는 이미 마우스 또는 트랙패드 클릭을 통해 피드백을 받을 수 있으므로 페이지 전환 애니메이션이 필요하지 않습니다.

Flutter는 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.pngPageTransitionsThemelib/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,
  );
}

iOS에 애니메이션이 없음

iOS에 애니메이션이 있음

문제가 있나요?

앱이 올바르게 실행되지 않는 경우 다음 링크의 코드를 사용하면 제대로 작동할 수 있습니다.

마우스 오버 상태 추가

데스크톱 앱에 모션을 추가하는 한 가지 방법은 마우스 오버 상태를 사용하는 것입니다. 사용자가 커서를 위로 가져가 마우스 오버하면 위젯에 상태(예: 색상, 모양, 콘텐츠)가 변경됩니다.

기본적으로 _OutlinedCardState 클래스('최근 재생' 재생목록 타일에 사용됨)는 MouseRegion을 반환합니다. 즉, 마우스 오버로 커서 화살표를 가리키면 포인터로 바뀌지만 더 많은 시각적 피드백을 추가할 수 있습니다.

a3c16fc17be25f6c.png lib/src/shared/views/outlined_card.dart를 열고 콘텐츠를 다음 구현으로 대체하여 _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 앱을 핫 리로드한 후 마우스를 최근 재생 재생목록 타일 중 하나로 가져갑니다.

61c08e46a5926e10.gif

OutlinedCard는 불투명도를 변경하고 모서리를 둥글게 만듭니다.

a3c16fc17be25f6c.png 마지막으로 lib/src/shared/views/hoverable_song_play_button.dart에 정의된 HoverableSongPlayButton 위젯을 사용하여 재생목록의 노래 번호를 재생 버튼으로 애니메이션 처리합니다. lib/src/features/playlists/view/playlist_songs.dart에서 Center 위젯(노래 번호 포함)을 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앱을 핫 리로드한 다음 오늘의 인기곡 또는 신곡 재생목록에서 노래 번호 위로 커서를 가져갑니다.

번호를 클릭하면 노래를 재생하는 재생 버튼으로 애니메이션이 적용됩니다.

82587ceb5452eedf.gif

GitHub에서 최종 프로젝트 코드를 확인합니다.

8. 수고하셨습니다.

이 Codelab을 완료했습니다. 앱에 적용할 수 있는 여러 가지 사소한 변경사항을 통해 더 보기 좋고, 접근성이 높으며, 더욱 현지화하기 쉽고, 여러 플랫폼에 더 적합하다는 것을 배웠습니다. 이러한 기법에는 다음이 포함되나 이에 국한되지 않습니다.

  • 서체: 텍스트는 단순한 커뮤니케이션 도구 그 이상입니다. 텍스트가 표시되는 방식을 활용하면 앱에 대한 사용자 경험과 인식에 긍정적인 영향을 미칩니다.
  • 테마 설정: 모든 위젯에 대한 디자인 결정을 내릴 필요 없이 안정적으로 사용할 수 있는 디자인 시스템을 설정합니다.
  • 적응성: 사용자가 앱을 실행하는 기기와 그 기능을 고려합니다. 화면 크기 및 앱 표시 방법을 고려합니다.
  • 모션 및 애니메이션: 앱에 움직임을 추가하면 사용자 환경에 에너지를 더해주며, 실질적으로는 사용자에게 피드백을 제공합니다.

약간의 변화를 통해 지루한 앱에서 멋진 앱으로 만들 수 있습니다.

이전

이후

다음 단계

Flutter에서 멋진 앱을 빌드하는 여러 가지 방법을 배우셨기를 바랍니다.

여기에 언급된 도움말 또는 유용한 정보를 적용하거나 공유할 팁이 있으면 언제든지 알려 주세요. Twitter(@rodydavis@khanhnwin)로 문의해 주세요.

다음 리소스도 유용할 수 있습니다.

테마 설정

적응형 리소스 및 반응형 리소스:

일반 디자인 리소스:

또한 Flutter 커뮤니티와 소통해 보세요.

더 나아가 앱 세상을 멋지게 만드세요!