Flutter 튜토리얼 35편: 대형 화면과 폴더블 대응
요약
핵심 요지
- 문제 정의: 모바일용으로 만든 앱이 태블릿이나 데스크톱에서는 화면을 제대로 활용하지 못하고, 입력 방식(마우스, 키보드)도 지원하지 않는다.
- 핵심 주장:
GridView1로 화면 공간을 효율적으로 활용하고, 입력 방식에 맞게 적응하며,Capability2와Policy3 패턴으로 플랫폼별 동작을 깔끔하게 관리해야 한다. - 주요 근거: 2024년 1월 기준 2억 7천만 대 이상의 Android 대형 화면 기기가 활성화되어 있고, iPadOS 앱 스토어 제출 가이드라인도 대형 화면 지원을 요구한다.
- 실무 기준: ListView를 GridView로 바꾸고, BottomNavigationBar와 NavigationRail을 화면 크기에 따라 전환하며, 마우스/키보드 입력을 지원한다.
문서가 설명하는 범위
- GridView를 활용한 대형 화면 레이아웃
- 폴더블 기기 대응 방법
- 마우스, 키보드, 스타일러스 입력 지원
- Tab 탐색과 키보드 단축키 구현
- Capability와 Policy 패턴으로 플랫폼별 분기 처리
읽는 시간: 18분 | 난이도: 중급
참고 자료
- Large screens & foldables - 대형 화면 레이아웃 가이드
- User input & accessibility - 입력 방식 적응 가이드
- Capabilities & policies - 플랫폼별 동작 관리 패턴
문제 상황
모바일 앱을 만들었는데 태블릿에서 실행하면 화면이 어색합니다.
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
NOTEAndroid와 Apple 모두 대형 화면 앱 가이드라인에서 “텍스트나 박스가 화면 전체 너비를 차지하면 안 된다”고 명시합니다. ListView를 그대로 사용하면 이 가이드라인을 위반하게 됩니다.
// ListView - 넓은 화면에서 아이템이 너무 늘어남ListView.builder(itemBuilder: (context, index) => Card(child: Text('Item $index'), // 화면 전체 너비 사용),)
What
NOTEGridView는 아이템을 2차원 그리드로 배치합니다. 화면이 넓어지면 열 수가 늘어나 공간을 효율적으로 사용합니다.
graph TD A[화면 너비 변화] --> B{GridView} B --> C[좁은 화면: 2열] B --> D[중간 화면: 3열] B --> E[넓은 화면: 4열]
delegate 용도 SliverGridDelegateWithFixedCrossAxisCount열 수 고정 SliverGridDelegateWithMaxCrossAxisExtent아이템 최대 너비 지정
How
TIPListView.builder를 GridView.builder로 교체
class AdaptiveGridList extends StatelessWidget {const AdaptiveGridList({super.key, required this.items});final List<Item> items;@overrideWidget 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;@overrideWidget 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
NOTEletterbox는 앱 창이 화면 중앙에 고정되고 주변이 검은색으로 둘러싸인 상태입니다. 폴더블을 펼쳤을 때 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;@overridevoid didChangeDependencies() {super.didChangeDependencies();_view = View.maybeOf(context);}@overridevoid 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});@overrideState<AdaptiveNavigation> createState() => _AdaptiveNavigationState();}class _AdaptiveNavigationState extends State<AdaptiveNavigation> {int _selectedIndex = 0;@overrideWidget build(BuildContext context) {final width = MediaQuery.sizeOf(context).width;final isWide = width >= 600;return Scaffold(body: Row(children: [// 넓은 화면: NavigationRailif (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(),],),),],),// 좁은 화면: BottomNavigationBarbottomNavigationBar: 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
NOTEAndroid 대형 화면 지원의 3단계(Tier 3)에는 마우스와 스타일러스 입력 지원이 포함됩니다. Material 3 버튼은 기본적으로 이를 지원하지만, 커스텀 위젯은 직접 구현해야 합니다.
데스크톱과 웹 사용자는 마우스 hover 효과를 기대합니다. 이 효과가 없으면 앱이 미완성처럼 보입니다.
What
NOTE지원해야 할 입력 방식
입력 방식 설명 구현 방법 마우스 hover 커서 올렸을 때 효과 MouseRegion스크롤 휠 마우스 휠 스크롤 ListenerTab 탐색 키보드로 포커스 이동 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;@overrideState<HoverCard> createState() => _HoverCardState();}class _HoverCardState extends State<HoverCard> {bool _isHovered = false;@overrideWidget 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;@overrideState<FocusableButton> createState() => _FocusableButtonState();}class _FocusableButtonState extends State<FocusableButton> {bool _hasFocus = false;@overrideWidget 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
WARNINGTab 순서 제어가 필요한 경우
기본 Tab 순서가 적합하지 않으면
FocusTraversalGroup9으로 그룹을 지정합니다.// 폼 필드를 모두 탐색한 후 제출 버튼으로 이동Column(children: [FocusTraversalGroup(child: MyFormWithMultipleFields(),),SubmitButton(), // 폼 탐색 완료 후 여기로 이동],)
결론: MouseRegion과 FocusableActionDetector를 사용하면 커스텀 위젯에도 마우스/키보드 상호작용을 추가할 수 있습니다.
챕터 5: 키보드 단축키 구현
Why
NOTE데스크톱과 웹 사용자는
Ctrl+N(새 문서),Delete(삭제) 같은 단축키를 기대합니다. 키보드는 강력한 입력 도구이므로 최대한 활용해야 합니다.
What
NOTEFlutter에서 키보드 단축키를 구현하는 세 가지 방법이 있습니다.
방법 범위 사용 상황 Focus/KeyboardListener10단일 위젯 특정 필드에서만 동작 Shortcuts+Actions위젯 트리 섹션 특정 화면에서만 동작 HardwareKeyboard전역 앱 전체에서 항상 동작
How
TIP방법 1: 단일 위젯에서 키 이벤트 처리
class KeyListeningField extends StatelessWidget {const KeyListeningField({super.key});@overrideWidget 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');@overrideWidget 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;@overrideState<GlobalShortcutHandler> createState() => _GlobalShortcutHandlerState();}class _GlobalShortcutHandlerState extends State<GlobalShortcutHandler> {@overridevoid initState() {super.initState();HardwareKeyboard.instance.addHandler(_handleKey);}@overridevoid 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');@overrideWidget 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});@overrideState<AdaptiveDensityApp> createState() => _AdaptiveDensityAppState();}class _AdaptiveDensityAppState extends State<AdaptiveDensityApp> {bool _touchMode = true;@overrideWidget 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});@overrideWidget 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
NOTECapability: 코드나 기기가 할 수 있는 것 Policy: 코드가 해야 하는 것
구분 Capability Policy 정의 기술적으로 가능한 것 비즈니스/정책적으로 허용되는 것 예시 카메라 존재 여부, API 지원 여부 앱 스토어 가이드라인, 디자인 결정 변경 시기 OS 업데이트, 하드웨어 변경 정책 변경, 디자인 결정 변경
How
TIPPolicy 클래스 만들기
/// 앱의 정책을 관리하는 클래스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();@overrideWidget 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
ListView나ScrollView같은 기본 스크롤 위젯은 마우스 휠을 자동으로 지원합니다. 하지만 커스텀 스크롤 동작이 필요하면 직접 구현해야 합니다.
How
TIPListener로 스크롤 휠 이벤트 감지
class CustomScrollWidget extends StatefulWidget {const CustomScrollWidget({super.key});@overrideState<CustomScrollWidget> createState() => _CustomScrollWidgetState();}class _CustomScrollWidgetState extends State<CustomScrollWidget> {double _scale = 1.0;@overrideWidget 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
-
GridView(그리드뷰): 아이템을 2차원 그리드 형태로 배치하는 스크롤 가능한 위젯이다. ↩
-
Capability(기능): 기기나 플랫폼이 기술적으로 수행할 수 있는 기능을 정의하는 클래스 패턴이다. ↩
-
Policy(정책): 비즈니스 규칙이나 앱 스토어 가이드라인에 따라 앱이 수행해야 하는 동작을 정의하는 클래스 패턴이다. ↩
-
ListView(리스트뷰): 아이템을 세로 또는 가로로 나열하는 스크롤 가능한 위젯이다. ↩
-
letterbox(레터박스): 앱 창이 화면 중앙에 고정되고 나머지 영역이 검은색으로 채워진 상태를 말한다. ↩
-
Display(디스플레이): Flutter 3.13에서 추가된 API로, 물리적 화면의 크기, 픽셀 밀도, 주사율 정보를 제공한다. ↩
-
FocusTraversalGroup(포커스 탐색 그룹): Tab 키로 포커스가 이동하는 순서를 그룹화하여 제어하는 위젯이다. ↩
-
KeyboardListener(키보드 리스너): 키보드 이벤트를 감지하여 처리하는 위젯이다. ↩
-
VisualDensity(시각적 밀도): Material 위젯의 크기와 간격을 조절하여 터치 또는 마우스 입력에 최적화하는 설정이다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!