ViewModel 中的 UI 状态 - 以 Selection state 为例

在一个应用程序中,有着各种不同类型的资料,这些不同的资料也有属於他们的生命周期,有些资料就像之前介绍的便利贴一样,是永久存在云端上的(除非有一天把专案删了...),有些资料的生命周期是 App 删掉才会消失的的,像是登入状态、或是 UUID 等等。还有些生命周期更短暂的资料,只存在於 App 开启到关闭的这段时间,更小的还有只存在在一个页面中的,像是今天要介绍的 Selection state 就是属於这种。

上述所说资料的生命周期对於架构设计来说是一个很重要的观念,如果没有妥善的管理,专案後期将会有大量的 Singleton 在记忆体中,这样所带来的後果是,为了资料的正确性,会对 Singleton 做很多建置跟清理的动作,通常来说这不会是一件好事。


再度分析需求

在第二阶段中,我们所要新增的新功能是:改变颜色、删除、新增、改变文字内容。其中新增是最简单的,只要加一个按钮即可,使用者便可以很直觉的按下这个按钮,最後看到新的便利贴出现在萤幕上。但是对於删除功能的话,对於使用者来说,必需要先选择要删除的便利贴是哪一个,才可以进行删除,而且对於改彦颜色、改变文字内容也是一样,所以看来我们得要有一个新的概念出现在便利贴中了:那就是选择状态。

除了选择状态,还要有选择状态触发之後的行动(删除、更改颜色等等),所以我们还要有一个选单来给使用者做操作,然後在没有选择状态时,使用者才可以新增便利贴,於是我们可以依照以上的这几点来写一个粗略的规格出来:

  • 刚进入 App 时,画面上会有一个新增按钮,点击之後可以新增一个便利贴
  • 点击便利贴时,会进入选择状态
    • 进入选择状态时,新增按钮会隐藏起来,并出现选单
    • 再次点击同一个便利贴时,会取消选择状态
    • 点击其他便利贴时,会切换选择目标
    • 点击空白区域时,会取消选择状态

接下来我们就依据这些需求去设计 ViewModel 吧!

ViewModel 实作

class EditorViewModel( // [1]
    private val noteRepository: NoteRepository
): ViewModel() {

	val allNotes: Observable<List<Note>> = noteRepository.getAllNotes()
  val selectingNote: Observable<Optional<Note>> = TODO() // [2]

  fun moveNote(noteId: String, positionDelta: Position) { ... }
  fun tapNote(note: Note) { TODO() } // [3]
  fun tapCanvas() { TODO() } // [4]

}
  • [1] 我重新命名了 BoardViewModel ,将它改为 EditorViewModel ,因为现在比较像是一个编辑器在做的事了,有选择状态,之後也会把改变颜色、删除、新增便利贴等等也放到这个 ViewModel 。
  • [2] View 需要知道选择中的便利贴是哪一个,同时这状态也是一个 Observable ,如此一来就算是切换选择目标,或是取消该选择,订阅该 Observable 的 Observer 会马上做出反应。
  • [3] 点击便利贴的公开函式,会影响到选择状态
  • [4] 点击空白处的公开函式,会影响到选择状态

首先来看到 selectingNote ,这边要怎麽来实作呢?以最直觉看法来说,我们只要将 tapNote 中的 note 带给 selectingNote 就可以了对吧?那要怎麽将资料喂给这个 Observable 呢?还记得我们之前使用过的 BehaviorSubject 吗?看来又是它可以好好发挥的时候到了:

val selectingNoteSubject = BehaviorSubject.create<Optional<Note>>()
val selectingNote: Observable<Optional<Note>> = selectingNoteSubject.hide()

fun tapNote(note: Note) { 
    selectingNoteSubject.onNext(Optional.of(note))
} 

fun tapCanvas() { 
    selectingNoteSubject.onNext(Optional.empty())
} 

ViewModel 的实作完成了,接下来换到 View 了。

View 实作

再帮大家复习一下之前 BoardView ,boardViewModel 被当作参数放进来,notes 的资料就可以因此跟 ViewModel 做资料绑定。

@Composable
fun BoardView(boardViewModel: BoardViewModel) {
    val notes by boardViewModel.allNotes.subscribeAsState(initial = emptyList())

    Box(Modifier.fillMaxSize()) {
        notes.forEach { note ->
            val onNotePositionChanged: (Position) -> Unit = { delta -> // [1]
                boardViewModel.moveNote(note.id, delta)
            }
            
            StickyNote(
                modifier = Modifier.align(Alignment.Center), // [2]
                onPositionChanged = onNotePositionChanged,
                note = note)
        }
    }
}

但是现在要新增一个按钮以及便利贴的选单,既有的 UI 已经不符合需求了,需要做相对应的修改,为了让 BoardView 维持职责单一,选单的部分就不会放到这里面了,而是会将他们放到一个新的 Composable function : EditorScreen ,他们的关系如下图所示:

Screen Shot 2021-09-12 at 12.10.15 PM.png

再加上之前的 BoardViewModel 已经重新命名为 EditorViewModel ,这里再用同样的作法也不恰当,所以这里我改成使用参数的方式将这些资料传递进来:

@Composable
fun BoardView(
    notesState: State<List<Note>>,
    selectedNoteState: State<Optional<Note>>, // [1]
    updateNotePosition: (String, Position) -> Unit,
    onNoteClicked: (Note) -> Unit // [2]
) {
    val notes by notesState
    val selectedNote by selectedNoteState

    Box(Modifier.fillMaxSize()) {
        notes.forEach { note ->
            val onNotePositionChanged: (Position) -> Unit = { delta ->
                updateNotePosition(note.id, delta)
            }

            val selected = selectedNote.filter { it.id == note.id }.isPresent // [3]

            StickyNote(
                modifier = Modifier.align(Alignment.Center),
                note = note,
                selected = selected,
                onPositionChanged = onNotePositionChanged,
                onClick = onNoteClicked
            )
        }
    }
}

除了之前本来就有的 notesupdateNotePosition 之外,这次新增了 [1] selectedNoteState 跟 [2] onNoteClicked 。selectedNoteState 被当作参数传进来之後,一样使用 delegate 的语法 - by 来取值,然後在 notes 的每一个回圈中检现在正在显示的这个便利贴是不是正在选择的那一个,也就是 [3] 的这个地方,如果刚好吻合的话 selected 的值就会是 true。

Optional 也可以使用 filter? 是的你没看错,filter 不是只有 List 或是 Observable 的专利,事实上 Optional 还有更多的 operator 像是 mapflatMap

StickyNote 的实作

// [2]
private val highlightBorder: @Composable Modifier.(Boolean) -> Modifier = { show ->
    if (show) {
        this.border(2.dp, Color.Black, MaterialTheme.shapes.medium)
    } else {
        this
    }.padding(8.dp)
}

@Composable
fun StickyNote(
    modifier: Modifier = Modifier,
    onPositionChanged: (Position) -> Unit = {},
    onClick: (Note) -> Unit,
    note: Note,
    selected: Boolean,
) {
    val offset by animateIntOffsetAsState( // [1]
        targetValue = IntOffset(
            note.position.x.toInt(),
            note.position.y.toInt()
        )
    )

    Surface(
        modifier.offset { offset }
            .size(108.dp, 108.dp)
            .highlightBorder(selected), // [2]
        color = Color(note.color.color),
        elevation = 8.dp
    ) {
        Column(modifier = Modifier
            .clickable { onClick(note) } // [3]
            .pointerInput(note.id) {
                detectDragGestures { change, dragAmount ->
                    change.consumeAllChanges()
                    onPositionChanged(Position(dragAmount.x, dragAmount.y))
                }
            }
            .padding(16.dp)
        ) {
            Text(text = note.text, style = MaterialTheme.typography.h5)
        }
    }
}

以上是修改过後的 StickyNote,首先来看 [1] 这个地方,这边使用了一个 animation 的 api ,由於移动便利贴时是一连串不连续的资料,所以在萤幕的显示方面看起来就不是非常的顺畅,在使用 animateIntOffsetAsState 之後,藉由动画的帮助,便利贴的移动方式就变的更加顺畅了。

在 [2] 这个地方,我写了一个 extension function ,为了要显示 highlight 效果,我必须要额外写至少 3 - 4 行的程序码,而这些程序码如果再放到 StickyNote 这个 function 里面的话会更难管理,所以在这里我利用了 modifier 本身的特性,让 highlight 的功能是用修饰的方式添加在 modifier 里面,整体看起来可读性更高。最後 [3] ,使用简单内建的 clickable 来监听点击事件,再传送到外面给 ViewModel 来决定选择状态。

EditorScreen 的实作

@ExperimentalAnimationApi
@Composable
fun EditorScreen(
    viewModel: EditorViewModel,
) {
    Surface(color = MaterialTheme.colors.background) {
        Box(
            Modifier.fillMaxSize()
                .pointerInput("Editor") {
                    detectTapGestures { viewModel.tapCanvas() } // [1]
                }
        ) {
            val selectedNoteState = viewModel.selectingNote.subscribeAsState(initial = Optional.empty())
            val selectedNote by selectedNoteState

            BoardView(
                viewModel.allNotes.subscribeAsState(initial = emptyList()),
                selectedNoteState,
                viewModel::moveNote,
                viewModel::tapNote
            )
            // [2]
            AnimatedVisibility(
                visible = !selectedNote.isPresent,
                modifier = Modifier.align(Alignment.BottomEnd)
            ) {
                FloatingActionButton(
                    onClick = { viewModel.addNewNote() },
                    modifier = Modifier
                        .padding(8.dp)
                ) {
                    val painter = painterResource(id = R.drawable.ic_add)
                    Icon(painter = painter, contentDescription = "Add")
                }
            }
            // [3]
            AnimatedVisibility(
                visible = selectedNote.isPresent,
                modifier = Modifier.align(Alignment.BottomCenter)
            ) {
                MenuView()
            }

        }

    }
}

EditorScreen 的实作就相对单纯了:

  • [1] 点击空白地方的事件由最外面的 Box 来发送
  • [2] 建立新便利贴的按钮,其能不能显示在萤幕上是由 selectedNote 的状态来决定的,如果没有任何便利贴被选了,就显示该按钮
  • [3] 便利贴选单,其显示的逻辑跟上面相反。

MenuView 的实作

@Composable
fun MenuView(
    modifier: Modifier = Modifier
) {
    var expended by remember {
        mutableStateOf(false)
    }

    val selectedColor = YBColor.HotPink

    Surface(
        modifier = modifier.fillMaxWidth(),
        elevation = 4.dp,
        color = MaterialTheme.colors.surface
    ) {

        Row {
            IconButton(onClick = {} ) {
                val painter = painterResource(id = R.drawable.ic_delete)
                Icon(painter = painter, contentDescription = "Delete")
            }

            IconButton(onClick = {} ) {
                val painter = painterResource(id = R.drawable.ic_text)
                Icon(painter = painter, contentDescription = "Edit text")
            }

            IconButton(onClick = { expended = true }) {
                Box(modifier = Modifier
                    .size(24.dp)
                    .background(Color(selectedColor.color), shape = CircleShape))

                DropdownMenu(expanded = expended, onDismissRequest = { expended = false }) {
                    for (color in YBColor.defaultColors) {
                        DropdownMenuItem(onClick = {
                            expended = false
                            {}
                        }) {
                            Box(modifier = Modifier
                                .size(24.dp)
                                .background(Color(color.color), shape = CircleShape))
                        }
                    }
                }
            }
        }
    }

}

便利贴选单中有三个功能:从左至右分别是删除、编辑文字、改变颜色选单,相信在看了这麽多 Jetpack Compose 的程序码之後,这边应该不太需要我解说才是(其实是我懒XD)。其中最值得一提的是开头的 var expended by remember 这边,这边的状态是指改颜色的下拉式选单,如果 expended 为 true 的话,下拉式选单就会打开。後面的 remember 是为了让这个 composable function 在执行 recompose 的时候不会清空 expended 中的值,如果原本的值是 true 的话,recompose 之後也应该要记得是 true。

不了解 recompose 跟 remember 机制的可以参考我之前写的文章:https://tech.pic-collage.com/8915f95c41f3 (疑,我好像已经工商过一次了XD)

最後再让大家看看实际 UI 长什麽样子吧!(请注意这影片是完成第二部後的样子喔!现在程序的实作还没做完!)

https://user-images.githubusercontent.com/7949400/124440143-e457ba00-ddac-11eb-93f4-1d3470001528.gif

关於 UI 状态

今天的内容是 UI 状态的实作,根据之前在专案架构时所做的决定,我们会把所有的逻辑都放在 ViewModel 中,所以今天所储存的状态只停留在 ViewModel 中,没有再上传到 Firebase ,於是根据这个观察,在三层式的架构下,我们可以粗略分成两种类型的资料,第一种是资料层的资料,他们是会永久存在的,第二种是商业逻辑层的“暂态”资料,当应用程序关掉的时候就会被一起回收掉了,这时候让我们想想 Single source of truth 这个概念,对於整个 App 来说,Single source of truth 是这个概念只能应用在资料层吗?也许对於今天 ViewModel 的 UI 状态来说 ViewModel 层就是一个 Single source of truth。我们在进行架构设计的同时,应该好好来思考一下资料应该要流向哪边去,每一个元件各自的职责,以及唯一可以信任的资料来源在哪边,在明天的篇幅中,我们将会继续来探讨这一点。


<<:  Day 21. Hashicorp Vault: Path limit

>>:  Normal Map

Day 11:批次修改!!

昨天体验了一些快捷键和命令,今天要讲的是在 vim 中也很常用的搜寻与取代 搜寻与取代 vim 的搜...

[烧烤吃到饱-2] 好好吃肉韩式烤肉吃到饱-台中公益店 #中秋节烤肉精选店家

这样的食材,才299吃到饱,别挑剔了啦~ 这家好好吃肉,就位在前几天分享过的「咕咕家」正对面。 好好...

[Day 29] - React 前端串後端 - 查询订单

今天在测试的时候发生了一个笑话, 发查询订单的request到丰收款的api结果一直回"验...

[Day 17] Sass - Parent Selector

& 父选择器“&”通常与Sass的Nesting用法搭配使用, 当内层的选择器使用&...

《瞬间爆击或者持续伤害》

在长尾效应跟爆款理论中,实际上是边际效应的正反两面。 网路减少了边际效应的成本,让原本的长尾需求被收...