# Banner

## 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。
---