본문 바로가기
Flutter

[Flutter] Widget 위젯 배우기

by 붕어사랑 티스토리 2021. 7. 21.
반응형

https://flutter.dev/docs/development/ui/widgets-intro

 

Introduction to widgets

Learn about Flutter's widgets.

flutter.dev

 

 

0. 요약

  • StatelessWidget : 상태가 없는 위젯. 변화가 거의 없는 위젯은 이것으로 선언한다
  • StatefulWidget : state라는 데이터 변화를 감지하고, state가 변할시 위젯을 rebuild 하는 위젯. setState라는 함수를 통해 state변화를 감지하여야 한다
  • GestureDetector : 위젯을 이것으로 감싸면 유저의 input gesture를 감지할 수 있다
  • initState: StatefulWidget 생성시 초기에 딱 한번 호출. 이니셜라이징 할 곳은 이곳에 모아두자
  • dispose : StatefulWidget에서 state object가 필요없을시 불리는 함수. uninit 할곳은 이곳에 모아두자

 

 

 

 

Hello World

import 'package:flutter/material.dart';

void main() {
  runApp(
    Center(
      child: Text(
        'Hello, world!',
        textDirection: TextDirection.ltr,
      ),
    ),
  );
}

위 예시는 플러터의 가장 간단한 앱을 보여줍니다. runApp() 함수는 Widget을 인풋으로 받아 이걸 widget tree의 root 자리에 배치합니다.

 

위 예제에서 widget tree는 Center widget과 Text widget 으로 구성되어 있습니다. flutter 의 root widget은 화면 전체를 덮어주고 Text 위젯에서 Hello World를 표시합니다.

 

위젯의 종류는 두가지로 StatelessWidgetStatefulWidget이 있습니다. 두 위젯은 State를 관리하는가 안하는가의 차이에 따라 구분됩니다.

 

위젯은 주 목적은 build() 함수를 구현하는 것입니다.

 

 

 

기본 위젯

 

Text

텍스트 위젯은 앱에 텍스트를 작성해 줍니다.

 

Row, Column

flexible한 레이아웃을 만들어줍니다. 이름에 맞게 Rot와 Column 방향으로 각기 설정 할 수 있습니다.

해당 위젯으로 디자인 하는것은 웹이 flexbox layout model 에 달려 있습니다.

 

Stack

stack 위젯을 사용하면 수직 또는 수평으로 linearly 하게 위젯을 쌓는게 아닌 페인트를 겹겹이 칠하는 것 처럼 위젯을 구현 할 수 있습니다.

Stack의 자식 위젯으로 Positioned 위젯을 이용합니다. 이 Positioned 위젯을 이용하여 사용자는 스택의 상하좌우의 임의이 영역에 위젯을 쌓아 올릴 수 있습니다.

 

Container

container 위젯은 직사각형의 visual 요소를 만들어 줍니다. container 위젯은 BoxDecoration으로 꾸며 질 수 있습니다. 배경화면이나 경계, 그림자등을 꾸밀 수 있습니다. Container는 또한 margin이나 padding 그리고 constraint 값을 가질 수 있습니다. 또한 Contatiner는 matrix를 이용하여 3차원 형태의 공간으로 변형이 가능합니다.

 

아래는 플러터의 간단한 위젯 조합을 나타내는 예제입니다.

 

import 'package:flutter/material.dart';

class MyAppBar extends StatelessWidget {
  MyAppBar({this.title});

  // Fields in a Widget subclass are always marked "final".

  final Widget title;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 56.0, // in logical pixels
      padding: const EdgeInsets.symmetric(horizontal: 8.0),
      decoration: BoxDecoration(color: Colors.blue[500]),
      // Row is a horizontal, linear layout.
      child: Row(
        // <Widget> is the type of items in the list.
        children: <Widget>[
          IconButton(
            icon: Icon(Icons.menu),
            tooltip: 'Navigation menu',
            onPressed: null, // null disables the button
          ),
          // Expanded expands its child to fill the available space.
          Expanded(
            child: title,
          ),
          IconButton(
            icon: Icon(Icons.search),
            tooltip: 'Search',
            onPressed: null,
          ),
        ],
      ),
    );
  }
}

class MyScaffold extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Material is a conceptual piece of paper on which the UI appears.
    return Material(
      // Column is a vertical, linear layout.
      child: Column(
        children: <Widget>[
          MyAppBar(
            title: Text(
              'Example title',
              style: Theme.of(context).primaryTextTheme.title,
            ),
          ),
          Expanded(
            child: Center(
              child: Text('Hello, world!'),
            ),
          ),
        ],
      ),
    );
  }
}

void main() {
  runApp(MaterialApp(
    title: 'My app', // used by the OS task switcher
    home: MyScaffold(),
  ));
}

 

위 예제에서 pubspec.yml 파일에서 아래와 같이 uses-material-desing: true 라고 설정해 주어야 합니다. 그래야 Material icons를 사용 할 수 있습니다.

 

name: my_app
flutter:
  uses-material-design: true

 

 

 

Material Components 사용하기

플러터는 Material Design을 따르는 많은 위젯을 제공합니다. Material app은 MaterialApp 위젯으로 시작합니다.

Material앱은 Navigator라는 위젯을 제공합니다. Navigator는 위젯의 스택을 관리하며 각 스택은 "routes"라는 string 변수로 구분됩니다.

 

import 'package:flutter/material.dart';

void main() {
  runApp(MaterialApp(
    title: 'Flutter Tutorial',
    home: TutorialHome(),
  ));
}

class TutorialHome extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Scaffold is a layout for the major Material Components.
    return Scaffold(
      appBar: AppBar(
        leading: IconButton(
          icon: Icon(Icons.menu),
          tooltip: 'Navigation menu',
          onPressed: null,
        ),
        title: Text('Example title'),
        actions: <Widget>[
          IconButton(
            icon: Icon(Icons.search),
            tooltip: 'Search',
            onPressed: null,
          ),
        ],
      ),
      // body is the majority of the screen.
      body: Center(
        child: Text('Hello, world!'),
      ),
      floatingActionButton: FloatingActionButton(
        tooltip: 'Add', // used by assistive technologies
        child: Icon(Icons.add),
        onPressed: null,
      ),
    );
  }
}

위 예제는 앞선 예제에서 MyAppBar와 MyScaffold를 material.dart의 AppBar와 Scaffold 위젯으로 바꾼 예시입니다.

 

위 예제에서 보시면 위젯이 다른 위젯의 arguments로 전달되고 있습니다. Scaffold 위젯은 여러 다른 위젯을 named arguments로 받는것을 보실수 있습니다. 각각의 위젯들은 Scaffold layout에 적절하게 배치됩니다.

 

비슷하게 AppBar위젯에서도 leading 위젯, actions 위젯, title 위젯에 각각 맞는 위젯을 넘겨주고 있습니다.

 

이러한 패터은 flutter 프레임워크 전반적으로 걸쳐서 실행됩니다.

 

 

 

Gesture 다루기

 

대부분 어플리케이션은 유저와 소통하는 시스템을 갖추고 있습니다. 대표적인 방법은 GestureDetector를 활용하는 것입니다.

 

import 'package:flutter/material.dart';

class MyButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        print('MyButton was tapped!');
      },
      child: Container(
        height: 50.0,
        padding: const EdgeInsets.all(8.0),
        margin: const EdgeInsets.symmetric(horizontal: 8.0),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(5.0),
          color: Colors.lightGreen[500],
        ),
        child: Center(
          child: Text('Engage'),
        ),
      ),
    );
  }
}

void main() {
  runApp(
    MaterialApp(
      home: Scaffold(
        body: Center(
          child: MyButton(),
        ),
      ),
    ),
  );
}

위 예제에서 GestureDetector는 visual 적인 표현은 해주지 않습니다. 대신 유저의 의해 만들어진 gesture를 감지합니다.

유저가 Container 위젯을 클릭할 시, GestureDetector는 onTap() 을 호출합니다. 위 예제에서는 콘솔에 메세지를 출력해주고 있습니다. tap 제스쳐 외에 drag, scale등의 제스처를 감지할 수 있습니다.

 

 

많은 위젯들은 GestureDetector를 통해 다른위젯에 콜백을 전달합니다. 예를들어 IconButton, RaisedButton, FloatingActionButton 위젯은 onPressed() 라는, 유저가 위젯을 탭 했을때 호출되는 콜백을 가지고 있습니다.

 

 

 

인풋에 반응하여 위젯 바꾸기

지금까지 예제에서는 stateless 위젯을 이용해 왔습니다. Stateless 위젯은 부모 위젯으로부터 arguments를 final로 전달받습니다. 위젯이 build() 함수를 요청받으면 위젯은 전달받은 arguments를 새로운 위젯을 생성하는데 전달합니다.

 

위젯을 유저 인풋에 좀더 다양하게 반응하게 만들려면 위젯은 state라는 것을 가져야 합니다. Flutter는 이를 위해 StatefulWidget이라는 것을 제공합니다. StatefuldWidget은 State 오브젝트를 생성합니다. 이 State 오브젝트는 상태를 저장합니다.

 

아래예제는 앞어 언급한 RasedButton 활용하는 예제입니다.

 

import 'package:flutter/material.dart';

class Counter extends StatefulWidget {
  // This class is the configuration for the state.
  // It holds the values (in this case nothing) provided
  // by the parent and used by the build  method of the
  // State. Fields in a Widget subclass are always marked
  // "final".

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

class _CounterState extends State<Counter> {
  int _counter = 0;

  void _increment() {
    setState(() {
      // This call to setState tells the Flutter framework
      // that something has changed in this State, which
      // causes it to rerun the build method below so that
      // the display can reflect the updated values. If you
      // change _counter without calling setState(), then
      // the build method won't be called again, and so
      // nothing would appear to happen.
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    // This method is rerun every time setState is called,
    // for instance, as done by the _increment method above.
    // The Flutter framework has been optimized to make
    // rerunning build methods fast, so that you can just
    // rebuild anything that needs updating rather than
    // having to individually changes instances of widgets.
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        ElevatedButton(
          onPressed: _increment,
          child: Text('Increment'),
        ),
        SizedBox(width: 16),
        Text('Count: $_counter'),
      ],
    );
  }
}

void main() {
  runApp(
    MaterialApp(
      home: Scaffold(
        body: Center(
          child: Counter(),
        ),
      ),
    ),
  );
}

 

해당 예제에서는 onPressed 에 _increment 콜백을 다신걸 볼 수 있습니다. 다시 _increment 함수를 보시면 setState() 함수가 안에 있는것을 보실 수 있습니다. setState함수는 플러터에게 State의 change가 일어났다는것을 알립니다. setState함수가 호출되면 플러터는 build()메소드를 다시한번 수행합니다.

 

플러터는 StatefulWidget과 State 오브젝트를 분리해놓습니다. 그리고 각기 다른 라이프사이클을 가집니다.

 

widget은 현재상태를 나타내기위한 일시적인 오브젝트입니다. 이에반면 State오브젝트는 영구적이며 buid()함수에 의해 생성됩니다.

 

 

 

위 예제에서는 유저의 인풋을 받아 그대로 build() 함수에 사용합니다. 실제 좀더 복잡한 수준의 앱에서는 각기 다른 파트의 위젯이 다른 업무를 맡고 있습니다. 예를들어 기준으로 한 위젯에서는 유저가 주는 정보를 모으기 위한 인터페이스를 담당하고 다른 위젯에서는 이러한 정보변화를 감지하여 유저에게 보여주는 작업을 합니다.

 

플러터에서는 이러한 방식으로 위젯은 callback방식으로 위젯의 상위계층으로 올라가고 하위계층의 위젯은 이러한 정보변화를 표현합니다. 이러한 flow의 공통부모가 바로 State입니다.

 

아래 예제는 이러한 방식에 대한 예제입니다.

 

import 'package:flutter/material.dart';

class CounterDisplay extends StatelessWidget {
  CounterDisplay({required this.count});

  final int count;

  @override
  Widget build(BuildContext context) {
    return Text('Count: $count');
  }
}

class CounterIncrementor extends StatelessWidget {
  CounterIncrementor({required this.onPressed});

  final VoidCallback onPressed;

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: onPressed,
      child: Text('Increment'),
    );
  }
}

class Counter extends StatefulWidget {
  @override
  _CounterState createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _counter = 0;

  void _increment() {
    setState(() {
      ++_counter;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        CounterIncrementor(onPressed: _increment),
        SizedBox(width: 16),
        CounterDisplay(count: _counter),
      ],
    );
  }
}

void main() {
  runApp(
    MaterialApp(
      home: Scaffold(
        body: Center(
          child: Counter(),
        ),
      ),
    ),
  );
}

위 예제를 보시면 유저의 콜백으로 _increment를 호출하고 있습니다. 유저가 인터페이스로 press 정보를 주면 위젯의 상위계층으로 정보가 전달되어 State안에 있는 _increment 함수가 호출됩니다. 여기서 _increment는 앞서 배운 setState 함수를 호출하여 플러터에게 상태가 변화되었음을 알립니다. 이에 build()함수가 다시 호출되고 build안에 있는 CounterIncrementor와 CounterDisplay 위젯이 다시 생성됩니다.

 

위 예제는 앞선예제와 결과물은 같지만 stateless를 display하는데 사용하고 state를 가장 최상단 위젯으로 두어 앞서 말한 callback은 위로, display는 아래로 계층을 두어 작성하였음을 알 수 있습니다. 이러한 방법으로 좀더 높은수준의 encapsulation을 구현하고 관리면에서도 부모위젯만 관리하면 되는 구조를 완성하였습니다.

 

 

 

 

총정리

다음 예제는 앞서 배운 컨셉을 전부 포함하는 예제입니다. 가상의 쇼핑 앱이며 다양한 세일 상품을 보여주고 장바구니 기능을 제공합니다.

 

 

ShoppingListItem 클래스를 정의로 시작합니다.

 

일단 아래 예제는 완성본이 아니고 전체적인 구조만 보여줍니다. 구글이 이런것좀 그만해....

 

import 'package:flutter/material.dart';

class Product {
  const Product({required this.name});
  final String name;
}

typedef void CartChangedCallback(Product product, bool inCart);

class ShoppingListItem extends StatelessWidget {
  ShoppingListItem({
    required this.product,
    required this.inCart,
    required this.onCartChanged,
  }) : super(key: ObjectKey(product));

  final Product product;
  final bool inCart;
  final CartChangedCallback onCartChanged;

  Color _getColor(BuildContext context) {
    // The theme depends on the BuildContext because different
    // parts of the tree can have different themes.
    // The BuildContext indicates where the build is
    // taking place and therefore which theme to use.

    return inCart //
        ? Colors.black54
        : Theme.of(context).primaryColor;
  }

  TextStyle? _getTextStyle(BuildContext context) {
    if (!inCart) return null;

    return TextStyle(
      color: Colors.black54,
      decoration: TextDecoration.lineThrough,
    );
  }

  @override
  Widget build(BuildContext context) {
    return ListTile(
      onTap: () {
        onCartChanged(product, inCart);
      },
      leading: CircleAvatar(
        backgroundColor: _getColor(context),
        child: Text(product.name[0]),
      ),
      title: Text(product.name, style: _getTextStyle(context)),
    );
  }
}

void main() {
  runApp(
    MaterialApp(
      home: Scaffold(
        body: Center(
          child: ShoppingListItem(
            product: Product(name: 'Chips'),
            inCart: true,
            onCartChanged: (product, inCart) {},
          ),
        ),
      ),
    ),
  );
}

ShoppligListItem은 stateless 위젯으로 선언되었습니다. 앞서 stateless로 display 한다고 했으니 display가 되겠군요.

list는 상품 아이템입니다. 이게 화면에 출력되겠네요.

inCart 변수는 bool 자료형으로 코드를 보면 true false에 따라 색이 바뀌겠군요.

위 앱은 상품을 누르면 상품의 색이 바뀌는 앱이 되겠습니다.

 

유저가 list 아이템을 tap하면 위젯은 inCart 값을 즉시 바꾸지 않습니다. 대신 widget은 onCartChanged 함수를 콜백방식으로 부릅니다. 이러한 방식은 state를 위젯계층 상위에 놓게 하며 state를 좀더 오래 보존토록 합니다. 극단적인 예로 위젯에 저장된 state가 runApp까지 전달되어 앱의 lifetime동안 보존될 수 있습니다.

 

부모가 onCartChanged 콜백을 호출받으면 부모위젯은 state를 update합니다. 그리고 부모 위젯은 다시 rebuild를 하게되며 ShoppingListItem을 다시 생성하게 됩니다.

 

(플러터는 이러한 위젯 rebuild에 최적화 되어있다고 하네요. 그래서 성능에 큰 무리를 주지 않는다고 합니다.)

 

 

 

아래 예제는 위 예제의 완성본입니다.

 

import 'package:flutter/material.dart';

class Product {
  const Product({required this.name});
  final String name;
}

typedef void CartChangedCallback(Product product, bool inCart);

class ShoppingListItem extends StatelessWidget {
  ShoppingListItem({
    required this.product,
    required this.inCart,
    required this.onCartChanged,
  }) : super(key: ObjectKey(product));

  final Product product;
  final bool inCart;
  final CartChangedCallback onCartChanged;

  Color _getColor(BuildContext context) {
    // The theme depends on the BuildContext because different
    // parts of the tree can have different themes.
    // The BuildContext indicates where the build is
    // taking place and therefore which theme to use.

    return inCart //
        ? Colors.black54
        : Theme.of(context).primaryColor;
  }

  TextStyle? _getTextStyle(BuildContext context) {
    if (!inCart) return null;

    return TextStyle(
      color: Colors.black54,
      decoration: TextDecoration.lineThrough,
    );
  }

  @override
  Widget build(BuildContext context) {
    return ListTile(
      onTap: () {
        onCartChanged(product, inCart);
      },
      leading: CircleAvatar(
        backgroundColor: _getColor(context),
        child: Text(product.name[0]),
      ),
      title: Text(product.name, style: _getTextStyle(context)),
    );
  }
}

class ShoppingList extends StatefulWidget {
  ShoppingList({Key? key, required this.products}) : super(key: key);

  final List<Product> products;

  // The framework calls createState the first time
  // a widget appears at a given location in the tree.
  // If the parent rebuilds and uses the same type of
  // widget (with the same key), the framework re-uses
  // the State object instead of creating a new State object.

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

class _ShoppingListState extends State<ShoppingList> {
  Set<Product> _shoppingCart = Set<Product>();

  void _handleCartChanged(Product product, bool inCart) {
    setState(() {
      // When a user changes what's in the cart, you need
      // to change _shoppingCart inside a setState call to
      // trigger a rebuild.
      // The framework then calls build, below,
      // which updates the visual appearance of the app.

      if (!inCart)
        _shoppingCart.add(product);
      else
        _shoppingCart.remove(product);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Shopping List'),
      ),
      body: ListView(
        padding: EdgeInsets.symmetric(vertical: 8.0),
        children: widget.products.map((Product product) {
          return ShoppingListItem(
            product: product,
            inCart: _shoppingCart.contains(product),
            onCartChanged: _handleCartChanged,
          );
        }).toList(),
      ),
    );
  }
}

void main() {
  runApp(MaterialApp(
    title: 'Shopping App',
    home: ShoppingList(
      products: <Product>[
        Product(name: 'Eggs'),
        Product(name: 'Flour'),
        Product(name: 'Chocolate chips'),
      ],
    ),
  ));
}

 

ShoppingList 클래스는 StatefulWidget을 상속하며 state를 가집니다. ShoppingList위젯이 위젯 tree에 처음으로 insert될때, 플러터 프레임워크는 createState()함수를 호출하여 _ShoppingListState를 생성하고 위젯 트리에 연결시켜줍니다.

 

(여기서 state 클래스는 underscord _ 와 함께 오는걸 주목해주세요. 이것은 private 구현을 의미합니다. 즉 라이브러리 외부에서 볼 수 없습니다)  

 

부모의 위젯이 rebuild 되면 부모위젯은 새로운 ShoppingList 인스턴스를 생성합니다. 그러나 플러터는 이번에는 state를 다시 생성하기보단 이전에 있던 _ShoppingListState 인스턴스를 재활용 합니다.

 

ShoppingList 프로퍼티에 접근하기 위해서, _ShoppingListState는 widget 프로퍼티를 사용합니다.

        children: widget.products.map((Product product) {

부모의 위젯이 rebuild되고 새로운 ShoppingList가 생성되면. _ShoppingListState는 새로운 value로 다시 rebuild 합니다.

만약 widget 프로퍼티가 바뀌는걸 위젯에 알리고 싶다면, didUpdateWidget() 함수를 override 하면 됩니다.

 

(위의 말이 무슨말인지 이해가 안가 찾아보니 didupdateWidget() 함수는 widget이 업데이트때마다 불리는 함수라 합니다)

 

 

onCartChanged 콜백에서, _ShoppingListState는 _shoppingCart에서 프로덕트를 추가하고 지우는 것으로 상태를 변화 합니다. 플러터에게 이러한 state변화를 알리기위해서는 콜백을 setState()로 wrapping 해야 합니다. setState가 호출되면 widget은 dirty 상태가 되고 rebuild되도록 스케쥴 되며 이후 앱의 스크린이 업데이트 됩니다.

 

스테이트를 이러한 방법으로 관리함으로써 위젯의 creating, updating을 따로 분리하는게 아닌, 심플하게 build함수만 구현하여 위 작업들을 할 수 있습니다.

 

 

 

위젯 라이프사이클 이벤트 다루기

 

createState()가 call 되면, 플러터는 새로운 state object를 tree에 넣고  state object안에 있는 initState()를 호출합니다.

State 클래스의 subclass들은 initState 함수를 오버라이딩 하여 딱 한번 실행되는 작업을 수행 할 수 잇습니다. 예를들어 initState를 오버라이딩 하여 애니메이션을 넣거나 플랫폼 서비스 구독하는 작업을 할 수 있습니다. initState를 오버라이딩 할때 반드시 super.initState를 호출해야 합니다.

 

state object가 더이상 필요하지 않으면, 플러터는 state object안의 dispose() 함수를 호출합니다. 정리작업이 필요하시면 dispose() 함수를 오버라이딩 하세요. 예를들어 타이버를 취소하기위해 dispose를 오버라이딩하거나 플랫폼서비스 구독취소를 하려면 dispose를 수행하시면 됩니다. 마찬가지로 오버라이딩 할 때 super.dispose 를 호출해야합니다.

 

 

 

Keys

 

위젯이 rebuild 될 때  key값을 이용하여 위젯을 다른 위젯과 match 시킬 수 있습니다. 기본적으로 플러터는 현제 위젯을 이전에 빌드된 위젯과 runtimeType과 화면에 나타났던 순서 기준으로 match 시켜줍니다.

키를 이용하면, 플러터는 매칭될 두 위젯에게 같은 key값과 같은 runtimeType을 요구합니다.

 

key값은 비슷한타입의 많은 위젯을 빌드할때 유용합니다. 예를들어 ShoppingList 위젯은 ShopplingListItem 인스턴스를 많이 빌드해야 합니다.

 

키가 없으면 목록의 첫 번째 항목이 화면에서 스크롤되어서 더 이상 뷰포트에 표시되지 않더라도 현재 빌드의 첫 번째 항목은 항상 이전 빌드의 첫 번째 항목과 동기화됩니다.

 

반응형

댓글