# Banner ![image](https://hackmd.io/_uploads/rJMPUX_gbx.png) ## Dialog ``` typealias OnBannerDataChanged = (banner: Banner, position: OverlayPosition) -> Unit @Composable fun BannerDialog(show: Boolean, onDismiss: () -> Unit, overlayModel: OverlayViewModel) { val bannerSetting by overlayModel.banner.collectAsState() val banner = bannerSetting?.get() var dismissCount by remember { mutableStateOf(0) } var localBanner by remember(dismissCount) { mutableStateOf(banner!!.copy()) } var overlayPosition by remember(dismissCount) { mutableStateOf(bannerSetting!!.position.toOverlayPosition()) } val livestreamData = overlayModel.getOverlaySettings()?.liveStreamData?.liveStream FullscreenDialog( show = show, onDismiss = { dismissCount++ onDismiss() }, onDone = { val updatedBanner = localBanner.copy() val position = when (overlayPosition) { OverlayPosition.BottomStart -> BannerSetting.Position.BOTTOM_START OverlayPosition.BottomEnd -> BannerSetting.Position.BOTTOM_END else -> throw IllegalStateException("Unsupported position: $overlayPosition") } overlayModel.updateBannerContent(updatedBanner) overlayModel.updateBannerPosition(position) // 設為已確認 bannerSetting!!.confirm = true onDismiss() }, ) { BannerDialogContent( banner = localBanner, liveStream = livestreamData!!, position = overlayPosition, onDataChanged = { newBanner, newPosition -> localBanner = newBanner overlayPosition = newPosition }, ) } } @OptIn(ExperimentalComposeUiApi::class) @Composable fun BannerDialogContent( banner: Banner, liveStream: LiveStream, position: OverlayPosition, onDataChanged: OnBannerDataChanged ) { TwoColumns( first = { val keyboard = LocalSoftwareKeyboardController.current val focus = LocalFocusManager.current TextField( value = banner.heading, onValueChange = { newVal -> onDataChanged(banner.copy(_heading = newVal), position) }, textStyle = TextStyle.Default.copy(color = Color.LightGray), singleLine = true, keyboardActions = KeyboardActions { focus.moveFocus(FocusDirection.Next) }, keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), placeholder = { Text( text = stringResource( id = R.string.banner_title_placeholder ), color = Color.LightGray.muted, ) }, ) Spacer(modifier = Modifier.height(16.dp)) TextField( value = banner.title, onValueChange = { newVal -> onDataChanged(banner.copy(_title = newVal), position) }, textStyle = TextStyle.Default.copy(color = Color.LightGray), singleLine = true, keyboardActions = KeyboardActions { keyboard?.hide() }, keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), placeholder = { Text( text = stringResource( id = R.string.banner_content_placeholder ), color = Color.LightGray.muted, ) }, ) }, second = { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .windowInsetsPadding( WindowInsets.safeContent.only(WindowInsetsSides.Right) ) ) { val selectedIndex = Banner.DRAWABLE_MAP.keys.indexOf(banner.drawableId) val (firstIndex, lastIndex) = Banner.DRAWABLE_MAP.keys.indices.let { Pair(it.first, it.last) } val nextEnabled = selectedIndex < lastIndex val previousEnabled = selectedIndex > firstIndex Row( verticalAlignment = Alignment.CenterVertically, ) { IconButton( onClick = { val newSelectedIndex = max(firstIndex, selectedIndex - 1) onDataChanged( banner.copy( _drawableId = Banner.DRAWABLE_MAP.keys.elementAt( newSelectedIndex ) ), position ) }, enabled = previousEnabled, ) { Icon( imageVector = Icons.Default.ArrowBackIosNew, contentDescription = stringResource(id = R.string.previous), tint = if (previousEnabled) Color.LightGray else Color.LightGray.muted, ) } BannerImage( painter = painterResource(id = banner.drawableId), description = stringResource(id = banner.drawableDescriptionId), modifier = Modifier.weight(1f), title = banner.getTitle(liveStream), heading = banner.getHeading(liveStream), ) IconButton( onClick = { val newSelectedIndex = min(lastIndex, selectedIndex + 1) onDataChanged( banner.copy( _drawableId = Banner.DRAWABLE_MAP.keys.elementAt( newSelectedIndex ) ), position ) }, enabled = nextEnabled, ) { Icon( imageVector = Icons.Default.ArrowForwardIos, contentDescription = stringResource(id = R.string.next), tint = if (nextEnabled) Color.LightGray else Color.LightGray.muted, ) } } Spacer(modifier = Modifier.height(16.dp)) Row { LocationToggleButton( position = OverlayPosition.BottomStart, checked = position == OverlayPosition.BottomStart, onCheckedChange = { onDataChanged(banner, OverlayPosition.BottomStart) }, ) LocationToggleButton( position = OverlayPosition.BottomEnd, checked = position == OverlayPosition.BottomEnd, onCheckedChange = { onDataChanged(banner, OverlayPosition.BottomEnd) }, ) } } }, ) } @Composable fun BannerImage( painter: Painter, description: String, heading: String, title: String, modifier: Modifier = Modifier, onDrawn: () -> Unit = {}, ) { val density = LocalDensity.current val size = with(density) { painter.intrinsicSize.let { DpSize(it.width.toDp(), it.height.toDp()) } } var imageSize by remember { mutableStateOf(IntSize.Zero) } var position by remember { mutableStateOf(Offset.Zero) } BoxWithConstraints(modifier = modifier.size(size)) { constraints Image( modifier = Modifier .align(Alignment.Center) .onGloballyPositioned { imageSize = it.size position = it.positionInParent() }, painter = painter, contentDescription = description, contentScale = ContentScale.Fit, ) if (imageSize.width == 0 || imageSize.height == 0) { return@BoxWithConstraints } val scaleX = imageSize.width.toFloat() / painter.intrinsicSize.width * 3 / density.density val scaleY = imageSize.height.toFloat() / painter.intrinsicSize.height * 3 / density.density val positionOffset = with(density) { DpOffset(position.x.toDp(), position.y.toDp()) } val offsetX = positionOffset.x + 32.dp * scaleX val headingTextWidth = with(density) { (painter.intrinsicSize.width.toDp()) * 3 / 4 - offsetX } val titleTextWidth = with(density) { painter.intrinsicSize.width.toDp() - offsetX } Text( text = heading, fontSize = 9.sp * scaleX, modifier = Modifier .align(Alignment.TopStart) .offset(offsetX, positionOffset.y + 0.dp * scaleY) .width(headingTextWidth * scaleX), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Start, ) Text( text = title, fontSize = 14.sp * scaleX, modifier = Modifier .align(Alignment.BottomStart) .offset { IntOffset( offsetX.roundToPx(), (positionOffset.y - 4.dp * scaleY).roundToPx(), ) } .width(titleTextWidth * scaleX), color = Color.White, maxLines = 1, fontWeight = FontWeight.Bold, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Start, ) LaunchedEffect(imageSize) { onDrawn() } } } ``` ## Position ``` enum class OverlayPosition { TopStart, TopEnd, BottomStart, BottomEnd, } ``` --- # 重構步驟 ## 1. Extract Composable Components(提取組件) * BannerDialogContent * BannerTextField * BannerNavigation * BannerImage * BannerPositionSelector ## 2. 重構 BannerPositionSelector ``` @Composable fun BannerPositionSelector( modifier: Modifier, position: OverlayPosition, onPositionChange: (OverlayPosition) -> Unit ) { Row(modifier = modifier, horizontalArrangement = Arrangement.Center) { LocationToggleButton( position = OverlayPosition.BottomStart, checked = position == OverlayPosition.BottomStart, onCheckedChange = { onPositionChange(OverlayPosition.BottomStart) }, ) LocationToggleButton( position = OverlayPosition.BottomEnd, checked = position == OverlayPosition.BottomEnd, onCheckedChange = { onPositionChange(OverlayPosition.BottomEnd) }, ) } } ``` * Extract Callback Responsibility(提取回呼職責) 把 OnBannerDataChanged 原本負責的兩件事拆出來,拆分出 Banner、Position,Position 更新變成獨立的 callback。 * Separate Concerns(關注點分離) 原本單一 callback 承擔兩件事,把它拆分,讓每個 callback 對應單一邏輯。 * Decouple State Handlers(解耦狀態處理) 把 position 變更和 banner 變更從耦合狀態解放出來,讓 UI 與邏輯更乾淨。 * Improve API Cohesion(提升介面內聚性) BannerPositionSelector 原本要傳 banner,但它根本不該關心 banner。 讓它專注於 position → 介面內聚程度提升。 * Extract Stateless UI Component(提取無狀態 UI 元件) BannerPositionSelector 不再需要 banner → 真正純 UI * Refine State Flow(細緻化狀態流動) 資料流向被拆分後,BannerDialog 的狀態更新邏輯更直覺。 --- ## 3. 重構 BannerNavigation 原始版本的 `BannerNavigation`: * 同時處理 圖片索引邏輯 * 管理 上一張 / 下一張按鈕 UI * 呼叫 `onDataChanged` callback 且左右兩個 `IconButton` 幾乎是複製貼上,只差在 icon 與字串資源,導致: * UI 與狀態邏輯耦合度過高 * 重複程式碼多,維護成本高 * 主要 Composable 可讀性差(掩蓋了真正重要的「切換邏輯」) ``` @Composable fun BannerNavigation( banner: Banner, liveStream: LiveStream, position: OverlayPosition, onDataChanged: OnBannerDataChanged ){ val selectedIndex = Banner.Companion.DRAWABLE_MAP.keys.indexOf(banner.drawableId) val (firstIndex, lastIndex) = Banner.Companion.DRAWABLE_MAP.keys.indices.let { Pair(it.first, it.last) } val nextEnabled = selectedIndex < lastIndex val previousEnabled = selectedIndex > firstIndex Row( verticalAlignment = Alignment.Companion.CenterVertically, ) { IconButton( onClick = { val newSelectedIndex = max(firstIndex, selectedIndex - 1) onDataChanged( banner.copy( _drawableId = Banner.Companion.DRAWABLE_MAP.keys.elementAt( newSelectedIndex ) ), position ) }, enabled = previousEnabled, ) { Icon( imageVector = Icons.Default.ArrowBackIosNew, contentDescription = stringResource(id = R.string.previous), tint = if (previousEnabled) Color.Companion.LightGray else Color.Companion.LightGray.muted, ) } BannerImage( painter = painterResource(id = banner.drawableId), description = stringResource(id = banner.drawableDescriptionId), modifier = Modifier.Companion.weight(1f), title = banner.getTitle(liveStream), heading = banner.getHeading(liveStream), ) IconButton( onClick = { val newSelectedIndex = min(lastIndex, selectedIndex + 1) onDataChanged( banner.copy( _drawableId = Banner.Companion.DRAWABLE_MAP.keys.elementAt( newSelectedIndex ) ), position ) }, enabled = nextEnabled, ) { Icon( imageVector = Icons.Default.ArrowForwardIos, contentDescription = stringResource(id = R.string.next), tint = if (nextEnabled) Color.Companion.LightGray else Color.Companion.LightGray.muted, ) } } } ``` ### 3.1 拆 UI 元件:PrevButton / NextButton 第一步,把「上一張 / 下一張」這兩顆按鈕從 `BannerNavigation` 中抽出來,先做一個粗顆粒度的切分: ``` @Composable private fun PreviousButton( firstIndex: Int, selectedIndex: Int, onDataChanged: OnBannerDataChanged, position: OverlayPosition, previousEnabled: Boolean ) ``` ``` @Composable private fun NextButton( lastIndex: Int, selectedIndex: Int, onDataChanged: OnBannerDataChanged, position: OverlayPosition, nextEnabled: Boolean ) ``` 這個階段的按鈕元件還是知道: * `firstIndex` / `lastIndex` * `selectedIndex` * `position` * `onDataChanged` 也就是說,它仍然混有「邏輯 + UI」,但至少可視化結構已經被拆出來: 重構目標 * 將左右按鈕抽出為可組合的 UI 元件 * 將重複邏輯去除(DRY 原則) * `BannerNavigation` 不再直接塞滿 IconButton 細節, 只負責 orchestrate UI,而不被 UI 細節干擾,閱讀時可以先聚焦在「流程」。 ### 3.2 精煉按鈕:PreviousButton / NextButton 拆分後的按鈕只需要: * `enabled` * `onClick` 這讓 component 已經達到「UI 純粹化」,不再知道 banner 或 position 是什麼。 ``` @Composable private fun PreviousButton( previousEnabled: Boolean, onClick: () -> Unit ) { IconButton( onClick = onClick, enabled = previousEnabled, ) { Icon( imageVector = Icons.Default.ArrowBackIosNew, contentDescription = stringResource(id = R.string.previous), tint = if (previousEnabled) Color.Companion.LightGray else Color.Companion.LightGray.muted, ) } } ``` ``` @Composable private fun NextButton( nextEnabled: Boolean, onClick: () -> Unit ) { IconButton( onClick = onClick, enabled = nextEnabled, ) { Icon( imageVector = Icons.Default.ArrowForwardIos, contentDescription = stringResource(id = R.string.next), tint = if (nextEnabled) Color.Companion.LightGray else Color.Companion.LightGray.muted, ) } } ``` ### 3.3 再抽象化:提取共用 NavigationButton 進一步觀察可以發現: * PreviousButton / NextButton 仍有 95% 內容相同 * 差別只有: * icon * contentDescription 的字串 所以進一步提煉成更 generic 的按鈕元件: ```kotlin @Composable private fun NavigationButton( imageVector: ImageVector, labelRes: Int, enabled: Boolean, onClick: () -> Unit ) { IconButton(enabled = enabled, onClick = onClick) { Icon( imageVector = imageVector, contentDescription = stringResource(id = labelRes), tint = if (enabled) Color.LightGray else Color.LightGray.muted, ) } } ``` 而 Previous/Next 變成 thin wrapper: ```kotlin @Composable private fun PreviousButton(enabled: Boolean, onClick: () -> Unit) = NavigationButton(Icons.Default.ArrowBackIosNew, R.string.previous, enabled, onClick) @Composable private fun NextButton(enabled: Boolean, onClick: () -> Unit) = NavigationButton(Icons.Default.ArrowForwardIos, R.string.next, enabled, onClick) ``` ### 3.4 BannerNavigation UI 更乾淨 ```kotlin @Composable fun BannerNavigation( banner: Banner, liveStream: LiveStream, position: OverlayPosition, onDataChanged: OnBannerDataChanged ) { val selectedIndex = Banner.Companion.DRAWABLE_MAP.keys.indexOf(banner.drawableId) val (firstIndex, lastIndex) = Banner.Companion.DRAWABLE_MAP.keys.indices.let { Pair(it.first, it.last) } val nextEnabled = selectedIndex < lastIndex val previousEnabled = selectedIndex > firstIndex Row( verticalAlignment = Alignment.Companion.CenterVertically, ) { NavigationButton( imageVector = Icons.Default.ArrowBackIosNew, id = R.string.previous, enabled = previousEnabled, onClick = { val newSelectedIndex = max(firstIndex, selectedIndex - 1) onDataChanged( Banner( _drawableId = Banner.Companion.DRAWABLE_MAP.keys.elementAt( newSelectedIndex ) ), position ) }) BannerImage( painter = painterResource(id = banner.drawableId), description = stringResource(id = banner.drawableDescriptionId), modifier = Modifier.Companion.weight(1f), title = banner.getTitle(liveStream), heading = banner.getHeading(liveStream), ) NavigationButton( imageVector = Icons.Default.ArrowForwardIos, id = R.string.next, enabled = nextEnabled, onClick = { val newSelectedIndex = min(lastIndex, selectedIndex + 1) onDataChanged( Banner( _drawableId = Banner.Companion.DRAWABLE_MAP.keys.elementAt( newSelectedIndex ) ), position ) }) } } ``` ```kotlin @Composable private fun NavigationButton( imageVector: ImageVector, id: Int, enabled: Boolean, onClick: () -> Unit ) { IconButton( onClick = onClick, enabled = enabled, ) { Icon( imageVector = imageVector, contentDescription = stringResource(id = id), tint = if (enabled) Color.Companion.LightGray else Color.Companion.LightGray.muted, ) } } ``` * UI 與邏輯解耦 `BannerNavigation` 專心處理索引與狀態更新;按鈕元件專心畫 UI。 * 提高可讀性 打開檔案時,一眼就能看出「上一張 → Banner → 下一張」的結構與行為。 * 消除重複程式碼 Previous / Next 共同邏輯下沉到 `NavigationButton`,修改樣式時不會漏改。 * 元件可重用性提升 `NavigationButton` 可以在其他需要左右切換的 UI 重用。 --- ## 4. 抽象化 Banner Navigation 邏輯 在前一章完成 UI 元件拆分 與 按鈕抽象化 之後,可以觀察到: `BannerNavigation` 裡仍存在一段業務邏輯: * 以 DrawableId 形成列表 * 找出當前 banner 的 index * 判斷是否能往前/往後 * 根據 index 返回下一個 drawableId 這整段邏輯本質上是 列表索引的導航行為,跟 banner 本身無關,也跟 UI 無關。 因此,可以進一步把這段邏輯抽出成一個獨立類別,形成可重用的邏輯模組。 --- ### 4.1 抽象化 Navigation:導入 BannerNavigator 第一步,先讓 Banner 的索引邏輯從 UI 中脫離,形成 `BannerNavigator`: ```kotlin class BannerNavigator(private val banner: Banner) { private val keys = Banner.DRAWABLE_MAP.keys.toList() private val index = keys.indexOf(banner.drawableId) val canPrev: Boolean get() = index > 0 val canNext: Boolean get() = index < keys.lastIndex fun prev(): Banner { val newIndex = (index - 1).coerceAtLeast(0) return banner.copy(_drawableId = keys[newIndex]) } fun next(): Banner { val newIndex = (index + 1).coerceAtMost(keys.lastIndex) return banner.copy(_drawableId = keys[newIndex]) } } ``` 對應在 UI 裡的使用方式: ```kotlin @Composable fun BannerNavigation( banner: Banner, liveStream: LiveStream, position: OverlayPosition, onDataChanged: OnBannerDataChanged ) { val navigator = remember(banner) { BannerNavigator(banner) } Row(verticalAlignment = Alignment.CenterVertically) { NavigationButton( imageVector = Icons.Default.ArrowBackIosNew, id = R.string.previous, enabled = navigator.canPrev, onClick = { onDataChanged(navigator.prev(), position) } ) BannerImage(/* ... */) NavigationButton( imageVector = Icons.Default.ArrowForwardIos, id = R.string.next, enabled = navigator.canNext, onClick = { onDataChanged(navigator.next(), position) } ) } } ``` 這樣 UI 已經非常乾淨,但導覽邏輯依然綁死在 Banner 的 domain 上。 ### 4.2 泛用化邏輯:導入 ListNavigator`<T>` 為了讓任何 type 都能具備「可前後導航」,可以將邏輯提升成泛型: ```kotlin class ListNavigator<T>( private val items: List<T>, private val current: T ) { private val index = items.indexOf(current) val canPrev: Boolean get() = index > 0 val canNext: Boolean get() = index < items.lastIndex fun prev(): T = items[(index - 1).coerceAtLeast(0)] fun next(): T = items[(index + 1).coerceAtMost(items.lastIndex)] } ``` 這類邏輯: * 不依賴 Banner * 不依賴 Compose * 完全是 list navigation 行為 是一個非常純粹的可重用 domain-agnostic 工具類。 --- ### 4.3 抽象 UI 組件:Navigation`<T>` 導入泛型 `ListNavigator<T>` 之後,可以再抽象化 UI: 把「左右箭頭 + 中間內容」整個封裝成通用 Composable。 ```kotlin @Composable fun <T> Navigation( modifier: Modifier = Modifier, navigator: ListNavigator<T>, onChange: (T) -> Unit, centerContent: @Composable () -> Unit ) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { NavigationButton( imageVector = Icons.Default.ArrowBackIosNew, id = R.string.previous, enabled = navigator.canPrev, onClick = { onChange(navigator.prev()) } ) centerContent() NavigationButton( imageVector = Icons.Default.ArrowForwardIos, id = R.string.next, enabled = navigator.canNext, onClick = { onChange(navigator.next()) } ) } } ``` 功能: * `Navigation` 不需要知道 Banner * 不需要知道 drawableId * 不需要知道 position * 可應用於任何自訂資料列表(e.g. PhotoViewer、Stepper、流程導覽) ---- ### 4.4 BannerNavigation 使用泛化後的 Navigation UI * 從 `onDataChanged(banner, position)` **拆解出更單一職責的 callback** * `BannerNavigation` 只專注處理 **drawable 切換** * 不再關心 position、不再回傳整個 banner * API 更單純、更可測、更好抽換 ```kotlin @Composable fun BannerNavigation( modifier: Modifier, banner: Banner, liveStream: LiveStream, onDrawableChange: (Int) -> Unit ) { val drawableList = remember { Banner.DRAWABLE_MAP.keys.toList() } val navigator = remember(banner) { ListNavigator(drawableList, banner.drawableId) } Navigation( modifier = modifier, navigator = navigator, onChange = onDrawableChange, ) { BannerImage( painter = painterResource(id = banner.drawableId), description = stringResource(id = banner.drawableDescriptionId), title = banner.getTitle(liveStream), heading = banner.getHeading(liveStream), ) } } ``` 重構後的 BannerNavigation 具有: * 更高的可讀性 * 最小責任原則(Single Responsibility) * 完全不涉及 UI 元素細節(NavigationButton 已抽出) * 也不再涉及 index 計算(由 ListNavigator 處理) --- ### 4.6 最佳資料夾結構(符合 Android / Compose 分層) 不依賴 Compose 是邏輯類 但又與 UI 閱讀方向強相關 最自然放在` ui/common/navigator` ### 4.7 總結 * UI/邏輯徹底解耦 * 可重用的 `ListNavigator<T>` 成為通用工具 * `Navigation<T>` 變成可插入任意 UI 的泛用導覽元件 * feature UI (`BannerNavigation`) 變得更乾淨、可測試、更可維護 * 資料夾結構清晰分層,符合 Android/Compose 的實務習慣 ## 5. 重構 BannerTextField 原始版本的 `BannerTextField` 綁定了: * `Banner` 這個 domain model * `onDataChanged`(包含 position) * Banner 的 heading/title 更新邏輯 * TextField UI 導致: * UI 元件混入 domain 行為 * 不容易重用 * `BannerTextField` 永遠只能用在 BannerFeature 裡 * 測試與預覽都需要完整的 Banner + callback 原始版本範例: ```kotlin @Composable fun BannerTextField( banner: Banner, onDataChanged: OnBannerDataChanged, position: OverlayPosition ) { TextField( value = banner.heading, onValueChange = { newVal -> onDataChanged(Banner(_heading = newVal), position) }, ... ) TextField( value = banner.title, onValueChange = { newVal -> onDataChanged(Banner(_title = newVal), position) }, ... ) } ``` --- ### 5.1 拆分責任(Extract Model-Agnostic TextFields) 目的: * 移除 banner 依賴 * 移除 onDataChanged(含 position) * 讓 UI 元件只接收純文字與 callback * 提升 TextField 元件可重用性,變成真正的 UI component 重構後: ```kotlin @Composable fun BannerTextField( heading: String, onHeadingChange: (String) -> Unit, title: String, onTitleChange: (String) -> Unit ) ``` 重點: * `BannerTextField` 不再需要 Banner * 不再需要 position * 不再需要 OnBannerDataChanged * 變得乾淨、可重用、可預覽 * 更符合 Compose 的無狀態 UI(stateless + callback) --- ### 5.2 重構後在 BannerDialog 的使用方式 使用時由 Feature 層自行組合資料模型: ```kotlin BannerTextField( heading = banner.heading, onHeadingChange = { newHeading -> onDataChanged(banner.copy(_heading = newHeading), position) }, title = banner.title, onTitleChange = { newTitle -> onDataChanged(banner.copy(_title = newTitle), position) } ) ``` 這樣: * UI function 保持乾淨 * domain 更新邏輯保持在 Feature 層 * 責任明确分離 --- ### 5.3 重構價值 * UI 與 domain 解耦 `BannerTextField` 變成通用元件,不再硬依賴 Banner。 * 可重用性提升 任何需要兩個 TextField 的 UI 都能使用它,不再限於 banner 介面。 * 更符合 Compose stateless 原則 所有狀態由外部 owner(ViewModel / Dialog)管理。 * 更容易測試 UI 元件不需 mock Banner 或 Position,測試 TextField 行為變簡單。 * 可預覽(Preview)更容易 Preview 不用建立複雜 Banner、OverlayPosition。 ---