10 min read
Flutter

이 글은 Flutter에 있는 글들을 읽으며 정리하는 내용이기 때문에 링크에 다 있다. 혹시 Flutter를 자세히 공부하고 싶다면 내 글보다 링크를 추천한다.

들어가며

요새 Flutter 를 학습해보고 있다. 2.0 이 나오면서 더 관심을 갖게 됐는데, Linux와 Web을 동시에 개발할 수 있다는 것에 큰 매료가 됐다. 특히 Web의 CSS는 삽질이 좀 필요하고 Linux App은 여러 대안이 있지만 딱히 맘에 들지 않아 학습할 의지가 별로 없었다. 그 와중에 나온 Flutter는 Dart를 사용하여 Cross Platform(iOS, AOS, Web, Linux, Mac OS, Windows)를 지원하는 Framework로써 상당히 기대하고 있다.

예전에 Flutter를 배워야하는 3가지 이유라는 글을 썼는데, 거기서더 언급했듯이 Kotlin, Typescript, React를 해본적이 있다면 더 쉽게 익힐 수 있을거라 생각된다.

이 글을 쓰는 목적은 Flutter Tutorial과 문서들을 보며 알게된 내용들을 내 나름의 방식으로 기록하고자 함이다. 기본적으로 나는 Tutorial에서 설명하는 모든 코드를 typing 하는데, 그것만 갖고는 전체 원리를 다 파악하지 못하는 경우가 대부분이다. 실무에서 사용하며 오랜시간을 함께해야 체화가 되는데, 이 과정을 단축시켜주는게 Tutorial과 정리라고 생각한다.

혹시나 이 글을 읽는 사람이 있다면 나와 같은 방법을 써보는게 어떨까 한다.

Introduction to widgets

Building layouts

Adding interactive

위에서도 언급했듯이 Flutter는 React와 상당히 흡사하다. 그리고 Widget과 Interactive(상호작용)을 할 때도 비슷한 방식으로 할 수 있다. 아래 예제 코드가 있는데 살펴보면.

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

  @override
  _TapboxAState createState() => _TapboxAState();
}

class _TapboxAState extends State<TapboxA> {
  bool _active = false;

  void _handleTap() {
    setState(() {
      _active = !_active;
    });
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _handleTap,
      child: Container(
        child: Center(
          child: Text(
            _active ? 'Active' : 'Inactive',
            style: const TextStyle(fontSize: 32.0, color: Colors.white),
          ),
        ),
        width: 200.0,
        height: 200.0,
        decoration: BoxDecoration(
          color: _active ? Colors.lightGreen[700] : Colors.grey[600],
        ),
      ),
    );
  }
}

StatefulWidget 클래스를 생성하고 State 클래스를 반환한다. State 클래스에는 상태값(_active), 상태변경 함수(_handleTab), 그리고 build라는 override 함수가 있다. build 함수는 React의 render 함수와 마찬가지로 state 값이 변경되면 build함수가 호출되는 구조로 되어있다. 그리고 state값을 변경해주기 위해 setState() 를 호출하게 되는데, 이 또한 react와 상당히 비슷한 점이다.

여기선 간단히 설명했지만 자세히 알고 싶다면 이 링크를 참조해보고 tutorial을 한번해보자.

앱을 개발할 때, 화면 전환은 가장 많이 사용된다. React에서는 Router를 사용해 화면 전환을 하는데, Flutter는 Navigator 를 사용해 화면을 전환한다.

아래 코드가 가장 단순한 예다. Button을 각 스크린에 띄워 FirstRoute와 SecondRoute를 왕복할 수 있게 한 예제이다. 눈여겨봐야할 곳은 Navigator.push, Navigator.pop 함수다.

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

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: FirstRoute());
  }
}

class FirstRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('First Route'),
      ),
      body: Center(
          child: ElevatedButton(
        child: Text('Open Route'),
        onPressed: () {
          Navigator.push(
            context,
            MaterialPageRoute(builder: (context) => SecondRoute()),
          );
        },
      )),
    );
  }
}

class SecondRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Second Route'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            Navigator.pop(context);
          },
          child: Text('Go Back!'),
        ),
      ),
    );
  }
}

Animate a widget across screens

아래 코드는. Hero widget을 사용한 Animate 화면 전환이다. Hero animations은 이 페이지에 있는 영상에서 볼 수 있는데, 꽤 자주 사용되므로 알아두면 좋다. (나는 아직 초보라 잘 모름ㅋ)

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

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MainScreen()
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
          title: const Text('Main Screen')
      ),
      body: GestureDetector(
        onTap: () {
          Navigator.push(context, MaterialPageRoute(builder: (context) {
            return const DetailScreen();
          }));
        },
        child: Hero(
          tag: 'imageHero',
          child: Image.network(
            'https://picsum.photos/250?image=9',
          ),
        ),
      ),
    );
  }
}


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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: GestureDetector(
            onTap: () {
              Navigator.pop(context);
            },
            child: Center(
              child: Hero(
                tag: 'imageHero',
                child: Image.network(
                  'https://picsum.photos/250?image=9',
                ),
              ),
            )
        )
    );
  }
}

이름으로 라우트를 할 수 있는 기능이다. 특이했던건 route 를 하기 위해 web의 ’/’, ‘/second’ 같은걸 사용했던건데, 이걸 이용해서 web에서도 바로 routing이 된다. 아마 web과 함께 처리하기 위해 이런 방식을 쓰지 않았을까 추측해본다. 아래에서 코드를 확인할 수 있다.

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

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Named Routes Demo',
      initialRoute: '/',
      routes: {
        '/': (context) => const FirstScreen(),
        '/second': (context) => const SecondScreen()
      },
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('First Screen'),
      ),
      body: Center(
          child: ElevatedButton(
        onPressed: () {
          Navigator.pushNamed(context, '/second');
        },
        child: const Text('Launch Screen'),
      )),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Second Screen'),
      ),
      body: Center(
          child: ElevatedButton(
        onPressed: () {
          Navigator.pop(context);
        },
        child: const Text('Bo Back!'),
      )),
    );
  }
}

Pass arguments to a named route

다른 스크린에 Arguments를 넘기는 방법인데, 어렵지 않다.

Navigator.pushNamed 함수에 Route와 arguments를 함께 넘긴 후 해당 Screen에서 final args = ModalRoute.of(context)!.settings.arguments as ScreenArguments; 를 사용해 값을 가져올 수 있다. 자세한 내용은 아래 코드를 참조하자.

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

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      initialRoute: '/',
      routes: {
        '/': (context) => FirstScreen(),
        ExtractArgumentsScreen.routeName: (context) =>
            const ExtractArgumentsScreen()
      },
    );
  }
}

class FirstScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: const Text('First Screen')),
        body: Center(
          child: ElevatedButton(
            onPressed: () {
              Navigator.pushNamed(context, ExtractArgumentsScreen.routeName,
                  arguments: ScreenArguments('Extract Arguments Screen',
                      'This message is extracted in the build method.'));
            },
            child: const Text('Navigate to screen that extracts arguments'),
          ),
        ));
  }
}

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

  static const routeName = '/extractArguments';

  @override
  Widget build(BuildContext context) {
    final args = ModalRoute.of(context)!.settings.arguments as ScreenArguments;

    return Scaffold(
        appBar: AppBar(
          title: Text(args.title),
        ),
        body: Center(
          child: Text(args.message),
        ));
  }
}

class ScreenArguments {
  final String title;
  final String message;

  ScreenArguments(this.title, this.message);
}

Return data from a screen

다른 스크린에서 값을 반환하는 방법이다. 좀 특이한건 async, await 구문을 사용한다는 점인데, 그 외에 특별한 부분은 없다. Navigator.pop(context, DATA) 옆의 문법과 같이 pop 할 때, object (any type)을 넣어주면 된다.

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

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(initialRoute: '/', routes: {
      '/': (context) => const HomeScreen(),
      SelectionScreen.routeName: (context) => const SelectionScreen()
    });
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: const Text('Returning Data Demo'),
        ),
        body: const Center(child: SelectionButton()));
  }
}

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

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
        onPressed: () {
          _navigateAndDisplaySelection(context);
        },
        child: const Text('Pick an option, any option!'));
  }

  void _navigateAndDisplaySelection(BuildContext context) async {
    final result =
        await Navigator.pushNamed(context, SelectionScreen.routeName);

    ScaffoldMessenger.of(context)
      ..removeCurrentSnackBar()
      ..showSnackBar(SnackBar(content: Text('$result')));
  }
}

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

  static const routeName = '/selection';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: const Text('Pick an option')),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Padding(
                padding: const EdgeInsets.all(8),
                child: ElevatedButton(
                  onPressed: () {
                    Navigator.pop(context, 'Yep!');
                  },
                  child: const Text('Yep!'),
                ),
              ),
              Padding(
                  padding: const EdgeInsets.all(8),
                  child: ElevatedButton(
                    onPressed: () {
                      Navigator.pop(context, 'Nope.');
                    },
                    child: const Text('Nope.'),
                  ))
            ],
          ),
        ));
  }
}

Send data to a new screen

새로운 스크린에 데이터를 보낼 때, 위에서 언급된 방식도 있지만 클래스의 instance를 생성하며 constructor에 직접 넣는 방식도 있다. 아래 코드를 보면 const TodosScreen({Key? key, required this.todos}) : super(key: key); 이 구문이 있는데, 여기가 생성자 함수를 생성하고 값을 바로 입력받는 부분이다.

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

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final todos = List.generate(
      20,
      (i) => Todo(
        'Todo $i',
        'A description of what needs to be done for Todo $i',
      ),
    );

    return MaterialApp(
      initialRoute: '/',
      routes: {
        '/': (context) => TodosScreen(todos: todos),
        DetailScreen.routeName: (context) => DetailScreen()
      },
    );
  }
}

class TodosScreen extends StatelessWidget {
  const TodosScreen({Key? key, required this.todos}) : super(key: key);

  final List<Todo> todos;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Todos')),
      body: ListView.builder(
        itemCount: todos.length,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text(todos[index].title),
            onTap: () {
              Navigator.pushNamed(context, DetailScreen.routeName,
                  arguments: todos[index]);
            },
          );
        },
      ),
    );
  }
}

class DetailScreen extends StatelessWidget {
  static const routeName = '/detail';

  @override
  Widget build(BuildContext context) {
    final todo = ModalRoute.of(context)!.settings.arguments as Todo;

    return Scaffold(
      appBar: AppBar(
        title: Text(todo.title),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Text(todo.description),
      ),
    );
  }
}

class Todo {
  final String title;
  final String description;

  const Todo(this.title, this.description);
}

Http

Fetch data from the internet

이번 문서는 약간 어렵다. 외울게 많기 때문인데, 어떤게 있는지 먼저 꼽아보자

  1. Future - Http 응답을 반환하는 함수를 생성하는데 사용한다. Future와 await, async 를 조합해 사용하며 응답이 반환했을 때, unmarshaled object로 반환해준다.
  2. parse json from response - factory 함수를 이용해 json 을 반환받아 unmarshal 을 수행한다. typed 언어들은 이런부분들이 좀 귀찮더라.
  3. FutureBuilder - FutureBuilder 라고 widget 인데, 위에 언급한 Future의 반환값을 사용하기 위해 쓴다.

아주 어렵진 않으나 외워서 능숙하게 쓰려면 여러번 해봐야할것 같다.

import 'dart:convert';

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      initialRoute: '/',
      routes: {
        '/': (context) => MainScreen()
      },
    );
  }
}

class MainScreen extends StatefulWidget {
  @override
  State createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen> {
  late Future<Album> futureAlbum;

  @override
  void initState() {
    super.initState();
    futureAlbum = fetchAlbum();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: const Text('Fetch Data Example'),
        ),
        body: Center(
            child: FutureBuilder<Album>(
              future: futureAlbum,
              builder: (context, snapshot) {
                if (snapshot.hasData) {
                  return Text(snapshot.data!.title);
                } else if (snapshot.hasError) {
                  return Text('${snapshot.error}');
                }

                // By default, show a loading spinner.
                return const CircularProgressIndicator();
              },
            )
        )
    );
  }
}

Future<Album> fetchAlbum() async {
  final response = await http
      .get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1'));

  if (response.statusCode == 200) {
    // If the server did return a 200 OK response,
    // then parse the JSON.
    return Album.fromJson(jsonDecode(response.body));
  } else {
    // If the server did not return a 200 OK response,
    // then throw an exception.
    throw Exception('Failed to load album');
  }
}

class Album {
  final int userId;
  final int id;
  final String title;

  Album({
    required this.userId,
    required this.id,
    required this.title,
  });

  factory Album.fromJson(Map<String, dynamic> json) {
    return Album(
      userId: json['userId'],
      id: json['id'],
      title: json['title'],
    );
  }
}