Flutter 튜토리얼 35편: 대형 화면과 폴더블 대응

요약#

핵심 요지#

  • 문제 정의: 모바일용으로 만든 앱이 태블릿이나 데스크톱에서는 화면을 제대로 활용하지 못하고, 입력 방식(마우스, 키보드)도 지원하지 않는다.
  • 핵심 주장: GridView1로 화면 공간을 효율적으로 활용하고, 입력 방식에 맞게 적응하며, Capability2Policy3 패턴으로 플랫폼별 동작을 깔끔하게 관리해야 한다.
  • 주요 근거: 2024년 1월 기준 2억 7천만 대 이상의 Android 대형 화면 기기가 활성화되어 있고, iPadOS 앱 스토어 제출 가이드라인도 대형 화면 지원을 요구한다.
  • 실무 기준: ListView를 GridView로 바꾸고, BottomNavigationBar와 NavigationRail을 화면 크기에 따라 전환하며, 마우스/키보드 입력을 지원한다.

문서가 설명하는 범위#

  • GridView를 활용한 대형 화면 레이아웃
  • 폴더블 기기 대응 방법
  • 마우스, 키보드, 스타일러스 입력 지원
  • Tab 탐색과 키보드 단축키 구현
  • Capability와 Policy 패턴으로 플랫폼별 분기 처리

읽는 시간: 18분 | 난이도: 중급


참고 자료#


문제 상황#

모바일 앱을 만들었는데 태블릿에서 실행하면 화면이 어색합니다. ListView4로 만든 목록이 넓은 화면에서 너무 늘어져 보입니다. 마우스로 클릭해도 hover 효과가 없고, Tab 키로 화면을 이동할 수 없습니다.

// 모바일 전용 레이아웃 - 대형 화면에서 비효율적
Scaffold(
body: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(items[index].title),
subtitle: Text(items[index].description),
);
},
),
bottomNavigationBar: BottomNavigationBar(
items: [/* ... */],
),
)

문제는 다음과 같습니다.

  • 리스트 아이템이 화면 전체 너비를 차지해 읽기 어렵다.
  • 폴더블 기기를 펼치면 앱이 letterbox 상태가 된다.
  • 마우스 hover, 키보드 단축키가 작동하지 않는다.
  • 화면 하단 네비게이션이 넓은 화면에서 어색하다.

해결 방법#

대형 화면 지원은 단순히 화면 크기만 고려하는 것이 아닙니다. 입력 방식(터치, 마우스, 키보드)과 플랫폼별 정책까지 함께 고려해야 합니다.

챕터 1: GridView로 대형 화면 레이아웃 최적화#

Why#

NOTE

Android와 Apple 모두 대형 화면 앱 가이드라인에서 “텍스트나 박스가 화면 전체 너비를 차지하면 안 된다”고 명시합니다. ListView를 그대로 사용하면 이 가이드라인을 위반하게 됩니다.

// ListView - 넓은 화면에서 아이템이 너무 늘어남
ListView.builder(
itemBuilder: (context, index) => Card(
child: Text('Item $index'), // 화면 전체 너비 사용
),
)

What#

NOTE

GridView는 아이템을 2차원 그리드로 배치합니다. 화면이 넓어지면 열 수가 늘어나 공간을 효율적으로 사용합니다.

graph TD A[화면 너비 변화] --> B{GridView} B --> C[좁은 화면: 2열] B --> D[중간 화면: 3열] B --> E[넓은 화면: 4열]
delegate용도
SliverGridDelegateWithFixedCrossAxisCount열 수 고정
SliverGridDelegateWithMaxCrossAxisExtent아이템 최대 너비 지정

How#

TIP

ListView.builder를 GridView.builder로 교체

class AdaptiveGridList extends StatelessWidget {
const AdaptiveGridList({super.key, required this.items});
final List<Item> items;
@override
Widget build(BuildContext context) {
return GridView.builder(
// 아이템 최대 너비 200px, 화면에 맞춰 열 수 자동 조정
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 1.0,
),
itemCount: items.length,
itemBuilder: (context, index) {
return Card(
child: Center(child: Text(items[index].title)),
);
},
);
}
}

ConstrainedBox로 최대 너비 제한

class ConstrainedContent extends StatelessWidget {
const ConstrainedContent({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Center(
child: ConstrainedBox(
// Material 3 권장 최대 너비
constraints: const BoxConstraints(maxWidth: 840),
child: child,
),
);
}
}

Watch out#

WARNING

열 수를 기기 유형으로 하드코딩하지 마세요

// ✗ Bad - 기기 유형으로 열 수 결정
int columns = isTablet ? 3 : 2;
// ✓ Good - 화면 크기로 열 수 결정
// SliverGridDelegateWithMaxCrossAxisExtent가 자동 처리

멀티 윈도우 모드에서 앱이 작은 공간에서 실행될 수 있습니다. 항상 실제 창 크기를 기준으로 레이아웃을 결정하세요.

결론: GridView와 maxCrossAxisExtent를 사용하면 화면 너비에 따라 열 수가 자동으로 조정됩니다.


챕터 2: 폴더블 기기 대응#

Why#

NOTE

폴더블 기기에서 화면 방향을 잠그면 문제가 발생합니다. 접힌 상태에서는 정상이지만, 펼치면 앱이 letterbox5 상태가 됩니다.

// 화면 방향 잠금 - 폴더블에서 문제 발생
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);

What#

NOTE

letterbox는 앱 창이 화면 중앙에 고정되고 주변이 검은색으로 둘러싸인 상태입니다. 폴더블을 펼쳤을 때 portrait 호환 모드가 적용되면서 발생합니다.

두 가지 해결 방법이 있습니다.

방법설명
모든 방향 지원권장 방법, 화면 방향 제한 해제
물리적 디스플레이 크기 사용예외적 상황에서만 사용

How#

TIP

방법 1: 모든 방향 지원 (권장)

void main() {
WidgetsFlutterBinding.ensureInitialized();
// 화면 방향 제한 해제
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
runApp(const MyApp());
}

방법 2: Display API로 물리적 화면 크기 확인

Flutter 3.13에서 추가된 Display6 API를 사용합니다.

class _AppState extends State<App> with WidgetsBindingObserver {
ui.FlutterView? _view;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_view = View.maybeOf(context);
}
@override
void didChangeMetrics() {
// 물리적 디스플레이 정보 접근
final ui.Display? display = _view?.display;
if (display != null) {
final size = display.size;
final devicePixelRatio = display.devicePixelRatio;
final refreshRate = display.refreshRate;
// 물리적 화면 크기 기반 로직
}
}
}

Watch out#

WARNING

물리적 디스플레이 크기는 예외적으로만 사용하세요

대부분의 경우 MediaQuery.sizeOf(context)창 크기를 사용해야 합니다. 물리적 디스플레이 크기는 letterbox 상태 감지 등 특수한 경우에만 사용합니다.

// ✓ 일반적인 레이아웃 - 창 크기 사용
final windowSize = MediaQuery.sizeOf(context);
// ✓ 예외적 상황 - 물리적 디스플레이 크기 사용
final display = View.maybeOf(context)?.display;

결론: 모든 화면 방향을 지원하면 폴더블 기기에서도 앱이 전체 화면을 활용할 수 있습니다.


챕터 3: 적응형 네비게이션#

Why#

NOTE

BottomNavigationBar7는 모바일에서 잘 작동하지만, 넓은 화면에서는 어색합니다. 대형 화면에서는 NavigationRail8이 더 적합합니다.

// 모바일 전용 - 넓은 화면에서 어색
BottomNavigationBar(
items: [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
],
)

What#

NOTE

화면 너비에 따라 네비게이션 위젯을 전환합니다.

graph LR A[화면 너비] --> B{600px 기준} B -->|좁음| C[BottomNavigationBar] B -->|넓음| D[NavigationRail]
위젯적합한 화면위치
BottomNavigationBar모바일하단
NavigationRail태블릿, 데스크톱측면
NavigationDrawer넓은 화면측면 확장형

How#

TIP

화면 크기에 따른 네비게이션 전환

class AdaptiveNavigation extends StatefulWidget {
const AdaptiveNavigation({super.key});
@override
State<AdaptiveNavigation> createState() => _AdaptiveNavigationState();
}
class _AdaptiveNavigationState extends State<AdaptiveNavigation> {
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
final isWide = width >= 600;
return Scaffold(
body: Row(
children: [
// 넓은 화면: NavigationRail
if (isWide)
NavigationRail(
selectedIndex: _selectedIndex,
onDestinationSelected: (index) {
setState(() => _selectedIndex = index);
},
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.home),
label: Text('Home'),
),
NavigationRailDestination(
icon: Icon(Icons.search),
label: Text('Search'),
),
NavigationRailDestination(
icon: Icon(Icons.settings),
label: Text('Settings'),
),
],
),
// 메인 콘텐츠
Expanded(
child: IndexedStack(
index: _selectedIndex,
children: const [
HomeScreen(),
SearchScreen(),
SettingsScreen(),
],
),
),
],
),
// 좁은 화면: BottomNavigationBar
bottomNavigationBar: isWide
? null
: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (index) {
setState(() => _selectedIndex = index);
},
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.search),
label: 'Search',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Settings',
),
],
),
);
}
}

결론: 화면 너비에 따라 BottomNavigationBar와 NavigationRail을 전환하면 모든 화면에서 자연스러운 네비게이션을 제공합니다.


챕터 4: 마우스와 키보드 입력 지원#

Why#

NOTE

Android 대형 화면 지원의 3단계(Tier 3)에는 마우스와 스타일러스 입력 지원이 포함됩니다. Material 3 버튼은 기본적으로 이를 지원하지만, 커스텀 위젯은 직접 구현해야 합니다.

데스크톱과 웹 사용자는 마우스 hover 효과를 기대합니다. 이 효과가 없으면 앱이 미완성처럼 보입니다.

What#

NOTE

지원해야 할 입력 방식

입력 방식설명구현 방법
마우스 hover커서 올렸을 때 효과MouseRegion
스크롤 휠마우스 휠 스크롤Listener
Tab 탐색키보드로 포커스 이동FocusTraversalGroup
키보드 단축키Ctrl+N 등Shortcuts, Actions

How#

TIP

마우스 hover 효과 추가

class HoverCard extends StatefulWidget {
const HoverCard({super.key, required this.child, required this.onTap});
final Widget child;
final VoidCallback onTap;
@override
State<HoverCard> createState() => _HoverCardState();
}
class _HoverCardState extends State<HoverCard> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
// 마우스 커서 모양 변경
cursor: SystemMouseCursors.click,
// hover 상태 감지
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: _isHovered
? Colors.blue.withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: widget.child,
),
),
);
}
}

Tab 탐색과 포커스 상태

class FocusableButton extends StatefulWidget {
const FocusableButton({super.key, required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
@override
State<FocusableButton> createState() => _FocusableButtonState();
}
class _FocusableButtonState extends State<FocusableButton> {
bool _hasFocus = false;
@override
Widget build(BuildContext context) {
return FocusableActionDetector(
// 포커스 변화 감지
onFocusChange: (focused) => setState(() => _hasFocus = focused),
// Enter/Space 키 액션 정의
actions: <Type, Action<Intent>>{
ActivateIntent: CallbackAction<Intent>(
onInvoke: (intent) {
widget.onPressed();
return null;
},
),
},
child: Container(
decoration: BoxDecoration(
border: _hasFocus
? Border.all(color: Colors.blue, width: 2)
: null,
borderRadius: BorderRadius.circular(8),
),
child: ElevatedButton(
onPressed: widget.onPressed,
child: Text(widget.label),
),
),
);
}
}

Watch out#

WARNING

Tab 순서 제어가 필요한 경우

기본 Tab 순서가 적합하지 않으면 FocusTraversalGroup9으로 그룹을 지정합니다.

// 폼 필드를 모두 탐색한 후 제출 버튼으로 이동
Column(
children: [
FocusTraversalGroup(
child: MyFormWithMultipleFields(),
),
SubmitButton(), // 폼 탐색 완료 후 여기로 이동
],
)

결론: MouseRegion과 FocusableActionDetector를 사용하면 커스텀 위젯에도 마우스/키보드 상호작용을 추가할 수 있습니다.


챕터 5: 키보드 단축키 구현#

Why#

NOTE

데스크톱과 웹 사용자는 Ctrl+N(새 문서), Delete(삭제) 같은 단축키를 기대합니다. 키보드는 강력한 입력 도구이므로 최대한 활용해야 합니다.

What#

NOTE

Flutter에서 키보드 단축키를 구현하는 세 가지 방법이 있습니다.

방법범위사용 상황
Focus / KeyboardListener10단일 위젯특정 필드에서만 동작
Shortcuts + Actions위젯 트리 섹션특정 화면에서만 동작
HardwareKeyboard전역앱 전체에서 항상 동작

How#

TIP

방법 1: 단일 위젯에서 키 이벤트 처리

class KeyListeningField extends StatelessWidget {
const KeyListeningField({super.key});
@override
Widget build(BuildContext context) {
return Focus(
onKeyEvent: (node, event) {
if (event is KeyDownEvent) {
print('Key pressed: ${event.logicalKey}');
}
return KeyEventResult.ignored;
},
child: const TextField(
decoration: InputDecoration(
border: OutlineInputBorder(),
hintText: '키 입력을 감지합니다',
),
),
);
}
}

방법 2: Shortcuts로 특정 영역에 단축키 설정

// 단축키 Intent 정의
class CreateNewItemIntent extends Intent {
const CreateNewItemIntent();
}
class SaveIntent extends Intent {
const SaveIntent();
}
class ShortcutEnabledScreen extends StatelessWidget {
const ShortcutEnabledScreen({super.key});
void _createNewItem() => print('New item created');
void _save() => print('Saved');
@override
Widget build(BuildContext context) {
return Shortcuts(
// 키 조합을 Intent에 바인딩
shortcuts: const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.keyN, control: true):
CreateNewItemIntent(),
SingleActivator(LogicalKeyboardKey.keyS, control: true):
SaveIntent(),
},
child: Actions(
// Intent를 실제 메서드에 바인딩
actions: <Type, Action<Intent>>{
CreateNewItemIntent: CallbackAction<CreateNewItemIntent>(
onInvoke: (intent) => _createNewItem(),
),
SaveIntent: CallbackAction<SaveIntent>(
onInvoke: (intent) => _save(),
),
},
// 포커스가 있어야 단축키가 동작함
child: Focus(
autofocus: true,
child: const Scaffold(
body: Center(
child: Text('Ctrl+N: 새 항목\nCtrl+S: 저장'),
),
),
),
),
);
}
}

방법 3: 전역 키보드 리스너

class GlobalShortcutHandler extends StatefulWidget {
const GlobalShortcutHandler({super.key, required this.child});
final Widget child;
@override
State<GlobalShortcutHandler> createState() => _GlobalShortcutHandlerState();
}
class _GlobalShortcutHandlerState extends State<GlobalShortcutHandler> {
@override
void initState() {
super.initState();
HardwareKeyboard.instance.addHandler(_handleKey);
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_handleKey);
super.dispose();
}
bool _handleKey(KeyEvent event) {
// Shift 키가 눌려있는지 확인
final isShiftDown = HardwareKeyboard.instance.logicalKeysPressed
.intersection({
LogicalKeyboardKey.shiftLeft,
LogicalKeyboardKey.shiftRight,
})
.isNotEmpty;
// Shift+N 처리
if (isShiftDown && event.logicalKey == LogicalKeyboardKey.keyN) {
_createNewItem();
return true; // 이벤트 소비
}
return false; // 다른 핸들러에게 전달
}
void _createNewItem() => print('Global: New item created');
@override
Widget build(BuildContext context) => widget.child;
}

Watch out#

WARNING

전역 리스너 사용 시 주의사항

TextField에서 타이핑 중일 때 Delete 키가 단축키로 동작하면 안 됩니다. 전역 리스너는 직접 활성화/비활성화를 관리해야 합니다.

bool _handleKey(KeyEvent event) {
// 텍스트 입력 중이면 단축키 무시
if (_isTextFieldFocused) return false;
// 단축키 처리
// ...
}

결론: Shortcuts와 Actions를 사용하면 특정 영역에서만 동작하는 단축키를 안전하게 구현할 수 있습니다.


챕터 6: Visual Density로 터치/마우스 모드 전환#

Why#

NOTE

터치 입력은 손가락으로 하므로 버튼이 커야 합니다. 마우스는 정밀하므로 더 작은 요소도 클릭할 수 있습니다. 같은 앱이라도 입력 방식에 따라 UI 밀도가 달라야 합니다.

What#

NOTE

VisualDensity11는 Material 위젯의 크기를 조절합니다. 수평/수직 밀도를 -4.0 ~ 4.0 범위로 설정합니다.

의미사용 상황
-4.0가장 조밀데스크톱, 정보 밀도 높음
0.0기본일반 모바일
+4.0가장 느슨터치 최적화, 접근성

How#

TIP

입력 방식에 따른 VisualDensity 적용

class AdaptiveDensityApp extends StatefulWidget {
const AdaptiveDensityApp({super.key});
@override
State<AdaptiveDensityApp> createState() => _AdaptiveDensityAppState();
}
class _AdaptiveDensityAppState extends State<AdaptiveDensityApp> {
bool _touchMode = true;
@override
Widget build(BuildContext context) {
// 터치 모드: 0.0 (기본), 마우스 모드: -1.0 (조밀)
final densityAmt = _touchMode ? 0.0 : -1.0;
final density = VisualDensity(
horizontal: densityAmt,
vertical: densityAmt,
);
return MaterialApp(
theme: ThemeData(
visualDensity: density,
useMaterial3: true,
),
home: Scaffold(
appBar: AppBar(
title: const Text('Visual Density Demo'),
actions: [
Switch(
value: _touchMode,
onChanged: (value) => setState(() => _touchMode = value),
),
const Padding(
padding: EdgeInsets.only(right: 16),
child: Center(child: Text('Touch Mode')),
),
],
),
body: const Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ElevatedButton(
onPressed: null,
child: Text('Button'),
),
SizedBox(height: 16),
TextField(
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Text Field',
),
),
SizedBox(height: 16),
ListTile(
leading: Icon(Icons.settings),
title: Text('Settings'),
subtitle: Text('Configure your app'),
),
],
),
),
),
);
}
}

커스텀 위젯에서 VisualDensity 사용

class DensityAwareWidget extends StatelessWidget {
const DensityAwareWidget({super.key});
@override
Widget build(BuildContext context) {
final density = Theme.of(context).visualDensity;
// 1 밀도 단위 = 6 픽셀로 환산
final extraPadding = density.baseSizeAdjustment.dy * 6;
return Container(
padding: EdgeInsets.all(16 + extraPadding),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(8),
),
child: const Text('Density-aware content'),
);
}
}

결론: VisualDensity를 사용하면 터치와 마우스 입력 모드에 따라 UI 밀도를 자동으로 조절할 수 있습니다.


챕터 7: Capability와 Policy 패턴#

Why#

NOTE

Platform.isAndroid, Platform.isIOS, kIsWeb 같은 분기가 코드 전체에 흩어져 있으면 관리가 어렵습니다. 플랫폼이 추가되거나 정책이 바뀔 때마다 여러 곳을 수정해야 합니다.

// ✗ Bad - 분기가 여기저기 흩어져 있음
if (Platform.isIOS) {
// iOS 전용 코드
}
// 다른 파일에서...
if (!Platform.isIOS) {
// iOS가 아닐 때 코드
}

What#

NOTE

Capability: 코드나 기기가 할 수 있는Policy: 코드가 해야 하는

구분CapabilityPolicy
정의기술적으로 가능한 것비즈니스/정책적으로 허용되는 것
예시카메라 존재 여부, API 지원 여부앱 스토어 가이드라인, 디자인 결정
변경 시기OS 업데이트, 하드웨어 변경정책 변경, 디자인 결정 변경

How#

TIP

Policy 클래스 만들기

/// 앱의 정책을 관리하는 클래스
class AppPolicy {
/// iOS App Store 가이드라인으로 인해 외부 결제 링크 금지
bool get shouldAllowExternalPurchaseLink => !Platform.isIOS;
/// 웹에서는 다운로드 대신 미리보기 표시
bool get shouldShowPreviewInsteadOfDownload => kIsWeb;
/// 데스크톱에서는 키보드 단축키 안내 표시
bool get shouldShowKeyboardShortcuts =>
Platform.isWindows || Platform.isMacOS || Platform.isLinux;
}
// 사용 예시
class PurchaseButton extends StatelessWidget {
const PurchaseButton({super.key});
final _policy = const AppPolicy();
@override
Widget build(BuildContext context) {
if (!_policy.shouldAllowExternalPurchaseLink) {
return const SizedBox.shrink(); // iOS에서는 표시 안 함
}
return ElevatedButton(
onPressed: () => launchUrl(Uri.parse('https://store.example.com')),
child: const Text('Buy in Browser'),
);
}
}

Capability 클래스 만들기

/// 기기/플랫폼의 기능을 확인하는 클래스
class DeviceCapability {
/// 카메라 사용 가능 여부
Future<bool> get hasCameraCapability async {
// 플랫폼 API로 확인
return await _checkCameraPermission();
}
/// 생체 인증 지원 여부
bool get supportsBiometricAuth {
return Platform.isIOS || Platform.isAndroid;
}
/// 권한 다이얼로그 플로우 필요 여부
bool get requiresPermissionDialogFlow {
// Android 13+에서 알림 권한 필요
if (Platform.isAndroid) {
return _androidSdkVersion >= 33;
}
// iOS에서도 필요
if (Platform.isIOS) {
return true;
}
return false;
}
int get _androidSdkVersion => 33; // 실제로는 플랫폼 채널로 확인
Future<bool> _checkCameraPermission() async {
// 실제 권한 확인 로직
return true;
}
}

테스트에서 Mock 사용

// 테스트 가능한 구조
class PurchaseViewModel {
PurchaseViewModel({AppPolicy? policy})
: _policy = policy ?? const AppPolicy();
final AppPolicy _policy;
bool get showPurchaseButton => _policy.shouldAllowExternalPurchaseLink;
}
// 테스트
void main() {
test('iOS에서는 구매 버튼 숨김', () {
final mockPolicy = MockAppPolicy();
when(mockPolicy.shouldAllowExternalPurchaseLink).thenReturn(false);
final viewModel = PurchaseViewModel(policy: mockPolicy);
expect(viewModel.showPurchaseButton, false);
});
}

Watch out#

WARNING

메서드 이름은 “왜”를 설명하세요

// ✗ Bad - 기기 유형으로 이름 지정
bool isIOS() => Platform.isIOS;
// ✓ Good - 의도를 이름에 담음
bool shouldBlockExternalPayment() => Platform.isIOS;

나중에 Android에서도 같은 정책이 적용되면, 메서드 이름을 바꾸지 않고 구현만 수정하면 됩니다.

결론: Capability와 Policy 클래스로 플랫폼별 분기를 중앙 관리하면 테스트와 유지보수가 쉬워집니다.


챕터 8: 스크롤 휠 지원#

Why#

NOTE

ListViewScrollView 같은 기본 스크롤 위젯은 마우스 휠을 자동으로 지원합니다. 하지만 커스텀 스크롤 동작이 필요하면 직접 구현해야 합니다.

How#

TIP

Listener로 스크롤 휠 이벤트 감지

class CustomScrollWidget extends StatefulWidget {
const CustomScrollWidget({super.key});
@override
State<CustomScrollWidget> createState() => _CustomScrollWidgetState();
}
class _CustomScrollWidgetState extends State<CustomScrollWidget> {
double _scale = 1.0;
@override
Widget build(BuildContext context) {
return Listener(
onPointerSignal: (event) {
// 스크롤 이벤트인지 확인
if (event is PointerScrollEvent) {
setState(() {
// 스크롤 방향에 따라 확대/축소
_scale += event.scrollDelta.dy > 0 ? -0.1 : 0.1;
_scale = _scale.clamp(0.5, 3.0);
});
}
},
child: Transform.scale(
scale: _scale,
child: const Center(
child: FlutterLogo(size: 200),
),
),
);
}
}

결론: Listener 위젯으로 스크롤 휠 이벤트를 감지하면 확대/축소 같은 커스텀 동작을 구현할 수 있습니다.


한계#

  • Display API는 Flutter 3.13 이상에서만 사용할 수 있습니다.
  • 일부 폴더블 기기의 힌지 영역 감지는 플랫폼별 플러그인이 필요할 수 있습니다.
  • 전역 키보드 단축키는 다른 앱이나 시스템 단축키와 충돌할 수 있으므로 주의해야 합니다.

Footnotes#

  1. GridView(그리드뷰): 아이템을 2차원 그리드 형태로 배치하는 스크롤 가능한 위젯이다.

  2. Capability(기능): 기기나 플랫폼이 기술적으로 수행할 수 있는 기능을 정의하는 클래스 패턴이다.

  3. Policy(정책): 비즈니스 규칙이나 앱 스토어 가이드라인에 따라 앱이 수행해야 하는 동작을 정의하는 클래스 패턴이다.

  4. ListView(리스트뷰): 아이템을 세로 또는 가로로 나열하는 스크롤 가능한 위젯이다.

  5. letterbox(레터박스): 앱 창이 화면 중앙에 고정되고 나머지 영역이 검은색으로 채워진 상태를 말한다.

  6. Display(디스플레이): Flutter 3.13에서 추가된 API로, 물리적 화면의 크기, 픽셀 밀도, 주사율 정보를 제공한다.

  7. BottomNavigationBar(하단 네비게이션 바): 화면 하단에 위치하는 네비게이션 위젯으로, 주로 모바일 앱에서 사용한다.

  8. NavigationRail(네비게이션 레일): 화면 측면에 위치하는 네비게이션 위젯으로, 태블릿이나 데스크톱에서 사용한다.

  9. FocusTraversalGroup(포커스 탐색 그룹): Tab 키로 포커스가 이동하는 순서를 그룹화하여 제어하는 위젯이다.

  10. KeyboardListener(키보드 리스너): 키보드 이벤트를 감지하여 처리하는 위젯이다.

  11. VisualDensity(시각적 밀도): Material 위젯의 크기와 간격을 조절하여 터치 또는 마우스 입력에 최적화하는 설정이다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 35편: 대형 화면과 폴더블 대응
https://moodturnpost.net/posts/flutter/flutter-large-screens-foldables/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차