[Flutter responsive UI]
리스폰시브 앱은 UI의 레이아웃을 스크린 사이즈에 따라 적절히 잡아 줍니다.
이런 기능은 하나의 앱이 여러 사이즈의 디바이스에서 동작해야 할때 유용합니다.
예를들면, 핸드폰, 노트북, 테블릿 과 같은 환경을 말합니다.
사용자가 화면크기를 변경 하거나 화면을 회전 했을시 UI는 그에 맞춰 적절이 다시 배치 됩니다.
Flutter 에서도 디바이스의 화면 크기나 화면 방향(orientation)에 따라 반응하는 앱을 개발할 수 있습니다.
responsive design을 사용하는 방법은 크게 두가지 입니다.
LayoutBuilder
class 사용builder
속성에서BoxConstraints
object를 얻습니다.
제시된 속성을 기준으로 무엇을 출력할지 결정 합니다. 예를들어 만약maxWidth
가 width 중단점(제한점)을 넘어서면, 왼쪽에 list가 있는 row를 가진Scaffold
object를 return 합니다.
만약 더 좁다면, list를 포함한 drawer 를 가진Scaffold
object 를 return 합니다.
또한, 화면을 디바이스의 높이,가로세로 비율(aspect ratio), 기타 다른 속성을 기준으로 조정 할 수도 있습니다.
사용자가 화면을 회전 시키거나 하여 기준값이 변경되면, build function이 다시 수행 됩니다.
- build function에
MediaQuery.of()
method 사용 - 이 method는 화면크가, 화면 방향(orientation) 및 기타 현재 앱의 정보를 줍니다.
이 method는 화면의 크기 같은 변화 보다는 좀 더 명확한 변화에 대한 리스폰시브를 구현할때 유용합니다.
(예를들면 사용자가 화면 눞히기 버튼같은것을 눌러서 화면에 변화를 주는 경우)
만약 사용자가 어떤방식으로든 화면 사이즈를 바꾸었을때, 이 method를 사용하면 build function이 자동으로 수행 됩니다.
responsive UI를 위한 widget
AspectRatio : 지정된 비율로 자식 widget의 size를 조정합니다.
CustomSingleChildLayout : 자식의 위치와 크기를 delegate(대리자)에 위임 합니다.
CustomMultiChildLayout : delegate(대리자)를 사용하여 자식 위젯의 크기와 위치를 정합니다.
FittedBox : 자신의 크기에 맞춰 자식의 위치와 크기를 조정합니다.
FractionallySizedBox :사용가능한 공간으로 자식의 크기를 조정 합니다.
LayoutBuilder :부모 widget의 사이즈에 종속된 widget tree를 build 합니다.
MediaQuery 화면 오리엔테이션 이나 사이즈 기타 화면에대한 정보를 얻을 수 있습니다.
MediaQueryData window 와같은 media의 정보를 얻습니다.(사이즈,컬러, 패딩 등)
OrientationBuilder 부모 widget의 화면방향(orientation)에 따른 widget tree를 build 합니다.
[화면 정보 취득]
우리가 반응형 UI를 구성할때 주로 사용하는 정보는 아래와 같습니다.
- Orientation: 디바이스가 세로인지 가로인지 화면방향을 말합니다. Landscape(가로),Portrait(세로)
- ScreenSize: App이 사용하는 전체 Screen 사이즈를 말합니다.
- LocalWidgetSize: 현재 widget의 사이즈를 말합니다. App의 화면 사이즈와는 또 다른 정보로서, UI를 좀 더 세밀하게 조정 가능 합니다.
아래 두가지 method를 통해서 필요한 정보를 가져올 수 있습니다.
- MediaQuery: 화면방향(Orientation), 화면 크기 정보등을 반환합니다.
- LayoutBuilder: 이 위젯은 현재 위젯이 차지하고 있는 영역을 알려주는 BoxConstraint를 제공 합니다. 이를 통해 LocalWidgetSize 를 구할 수 있습니다.
Example
예제를 통해 위 정보를 어떻게 사용하는 지 살펴 보겠습니다.
class HomeView extends StatelessWidget {
const HomeView({Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
/*1*/
var mediaQuery = MediaQuery.of(context);
final orientation=mediaQuery.orientation;
final size=mediaQuery.size;
return Scaffold(
appBar: AppBar(
title: Text('Flutter responsive layout demo'),
),
body: Center(
child: Column(
children: [
/*2*/
Text('Orientation: $orientation'),
Text('Size: $size'),
Container(
color:Colors.black26,
width:250,
height:300,
/*3*/
child:LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final localWidgetSize=Size(constraints.maxWidth, constraints.maxHeight);
return Center(
child: Text('this is container: $localWidgetSize')
);
},
),
)
],
)
)
);
}
}
코드를 상세히 살펴 보겠습니다. (번호는 위 코드에 주석 되어 있는 번호와 매핑 됩니다)
1) MediaQuery.of(context) 를 이용해서 화면방향(orientation) 정보와, size 정보를 바로 취득 할 수있습니다.
2) MediaQuery.of(context) 를 이용해 받아온 정보를 Text widget을 이용해 출력 합니다. string 과 variable을 조합해서 사용하고 있습니다. Size의 첫번째 값은 width, 두번째 값은 height 를 나타냅니다.
3) LayoutBuilder 를 이용해서 widget의 size 정보를 얻을 수 있습니다. BoxConstraints를 이용해서 현재(LayoutBuilder의 부모)위젯의 크기를 알 수 있습니다. 전체 app 크기와 다른 정보가 출력되는 것을 확인 하실 수 있습니다.
위 코드를 실행하면 아래와 같은 결과를 얻을 수 있습니다.
화면을 회전시키면 회전된 화면 기준의 값이 출력 됨을 확인 할 수 있습니다.
responsive ui를 구축하기 위한 기본 screen 정보를 얻는 방법에 대해 알아 보았습니다.
[OrientationBuilder]
App을 만들때 가능하면 화면의 전체를 사용하여 정보를 보여주는것이 좋습니다.
가로모드일때, 세로 모드일때 또는 테블릿 등과 같이 큰 화면에서 UI 배치가 바뀌는게 사용자 입장에서는 더 좋겠죠.
OrientationBuilder 란 것을 이용하여 화면방향을 인식하고 적절히 UI를 변경 하겠습니다.
(물론 MediaQuery.of(context).orientation 를 이용해도 됩니다.)
아래 예제처럼 구성을 해보겠습니다. (가로일때와 세로 일때 레이아웃 배치가 바뀌고 있습니다. )


Example
//main.dart
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: OrientationBuilder(
builder: (context, orientation) {
/*1*/
return orientation == Orientation.portrait
? _buildVerticalLayout()
: _buildHorizontalLayout();
},
),
);
}
1) Orientation 정보를 읽어와서 화면 방향이 세로 이면 _buildVerticalLayout() 을 부르고, 가로이면 _buildHorizontalLayout()를 부르고 있습니다.
참고: 꼭 OrientationBuilder 를 쓰지 않더라도 MediaQuery.of(context).orientation 값을 이용하여 화면방향에 대한 정보를 얻을 수 있습니다.
_buildVerticalLayout,_buildHorizontalLayout 두 layout 코드를 자세히 살펴보진 않겠습니다. 특별하진 않으니 아래 sample code를 열어 보시면 어렵지 않게 이해 하실 수 있을 것입니다.
자세한 코드는 sample code 를 참고 하세요.
[Fragments]
일반적인 App 에서 Master-Detail flow 를 가진 화면이 많습니다.
Master 에서 item을 클릭하면 해당 item에 대한 Detail 페이지가 열리게 됩니다.
Gmail 앱 역시 Master-Detail flow 를 갖고 있습니다.
우리도 app을 이 구조로 만들어 보겠습니다.


만약 이 앱을 가로 모드로 실행한다면 어떨까요? 많은 영역이 낭비 되겠죠?
이부분을 어떻게 개선 할 수 있을까요?
한 화면에 Master 와 detail 을 한번에 보여주면 적절한 구조가 될 것입니다.
Fragment A 가 Master 이고 Fragment B가 Detail 입니다.
A 와 B를 한 화면에 보여주면 가로모드에서도 적절한 layout이 됩니다.
예제를 통해 직접 구현 해보겠습니다.
Example
아래와 같은 구조로 프로그램을 작성 하겠습니다.
1) 두개의 widget을 생성합니다. 하나는 Master 로서 list를 보여주고, 다른 하나는 Detail 정보를 보여주는 위젯 입니다.
2) 화면의 사이즈를 측정 합니다.
3) 화면이 좁으면 Master widget만 보여주고 Master의 item을 클릭시 Detail 화면으로 navigate 합니다.
화면이 충분히 넓으면 Master와 Detail widget을 한 화면에 보여 줍니다. Master 의 item을 탭 하면, 같은 화면상의 Detail widget에 해당 item 정보를 보여줍니다.
첫번째 list 위젯(Master) 생성
(widget은 아직 page에 넣지 않았기 때문에 화면에 보이지는 않습니다. page에 넣게 되면 아래와 같은 모양으로 보이게 될 것입니다. )
typedef Null ItemSelectedCallback(int value);
class ListWidget extends StatefulWidget {
final int count;
final ItemSelectedCallback onItemSelected;
ListWidget(
this.count,
this.onItemSelected,
);
@override
_ListWidgetState createState() => _ListWidgetState();
}
class _ListWidgetState extends State<ListWidget> {
@override
Widget build(BuildContext context) {
return ListView.separated(
separatorBuilder: (context, index) => Divider(
color: Colors.black38,
),
itemCount: widget.count,
itemBuilder: (BuildContext context, int index) {
return ListTile(
title: Center(child:Text('$index')),
onTap: () {
widget.onItemSelected(index);
},
);
},
);
}
}
두번째 Detail Widget 생성
(widget은 아직 page에 넣지 않았기 때문에 화면에 보이지는 않습니다. page에 넣게 되면 아래와 같은 모양으로 보이게 될 것입니다. )
class DetailWidget extends StatefulWidget {
final int data;
DetailWidget(this.data);
@override
_DetailWidgetState createState() => _DetailWidgetState();
}
class _DetailWidgetState extends State<DetailWidget> {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.blueGrey,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(widget.data.toString(), style: TextStyle(fontSize: 50.0, color: Colors.white),),
],
),
),
);
}
}
list widget과 detail widget을 만들었습니다. 이제 page(화면) 에 생성해둔 widget 을 출력 해야 합니다.
Main screen
class MasterDetailPage extends StatefulWidget {
@override
_MasterDetailPageState createState() => _MasterDetailPageState();
}
class _MasterDetailPageState extends State<MasterDetailPage> {
/*1*/
var selectedValue = 0;
var isLargeScreen = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title:Text('Master-Detail example')),
body: OrientationBuilder(builder: (context, orientation) {
/*2*/
if (MediaQuery.of(context).size.width > 600) {
isLargeScreen = true;
} else {
isLargeScreen = false;
}
return Row(children: <Widget>[
Expanded(
child: ListWidget(10, (value) {
/*3*/
if (isLargeScreen) {
selectedValue = value;
setState(() {});
} else {
Navigator.push(context, MaterialPageRoute(
builder: (context) {
return DetailPage(value);
},
));
}
}),
),
/*4*/
isLargeScreen ? Expanded(child: DetailWidget(selectedValue)) : Container(),
]);
}),
);
}
}
Main page에서 중요한 부분을 살펴 보겠습니다.
1) selectedValue
: 선택된 list item 값
isLargeScreen
: boolean 값으로 화면이 충분히 큰지 체크 하는 값입니다.
2) MediaQuery
를 이용하여 화면이 충분히 큰지 체크 합니다. 가로 600 pixel 이면 충분히 크다고 판단 합니다.
소스에서 가장 중요한 파트는 4)번 코드로 아래와 같습니다.
isLargeScreen ? Expanded(child: DetailWidget(selectedValue)) : Container(),
만약 스크린이 크면, 우리는 detail widget을 추가 하고, 스크린이 충분히 크지 않다면, 빈 container를 return 합니다.
Expanded widget을 이용해서 screent을 채웁니다. 작은 화면일때는 빈 container이기 때문에 영역을 차지 하지 않을 것이고 , 큰 화면이면 남은 영역을 DetailWidget이 차지 하게 될 것입니다. 차지하는 비율을 Flex 속성을 이용해 조정할 수도 있습니다.
두번째로 중요한 파트는 3)번 코드로 아래와 같습니다.
if (isLargeScreen) {
selectedValue = value;
setState(() {});
} else {
Navigator.push(context, MaterialPageRoute(
builder: (context) {
return DetailPage(value);
},
));
}
만약 화면이 충분히 크면 새로운 페이지로 navigate 할 필요가 없습니다. 화면이 작으면 navigator.push 를 통해 새로운 페이지로 이동 합니다.
작은 Screen을 위한 Detail Page
class DetailPage extends StatefulWidget {
final int data;
DetailPage(this.data);
@override
_DetailPageState createState() => _DetailPageState();
}
class _DetailPageState extends State<DetailPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title:Text('Master-Detail example')),
body: DetailWidget(widget.data),
);
}
}
하나의 detail widget을 페이지에 넣습니다. 이를 이용해 화면에 정보를 보여줍니다.
이제 우리는 서로다른 스크린 사이즈와 화면 방향에 대응하는 앱을 가졌습니다.
화면크기 및 화면 방향에 따라 layout이 적절히 바뀌는 responsive app에 대해 알아 봤습니다.
app이 다양한 device에서 동작하는 요즘은 responsive app은 필수 사항 입니다.