在一个应用程序中,有着各种不同类型的资料,这些不同的资料也有属於他们的生命周期,有些资料就像之前介绍的便利贴一样,是永久存在云端上的(除非有一天把专案删了...),有些资料的生命周期是 App 删掉才会消失的的,像是登入状态、或是 UUID 等等。还有些生命周期更短暂的资料,只存在於 App 开启到关闭的这段时间,更小的还有只存在在一个页面中的,像是今天要介绍的 Selection state 就是属於这种。
上述所说资料的生命周期对於架构设计来说是一个很重要的观念,如果没有妥善的管理,专案後期将会有大量的 Singleton 在记忆体中,这样所带来的後果是,为了资料的正确性,会对 Singleton 做很多建置跟清理的动作,通常来说这不会是一件好事。
在第二阶段中,我们所要新增的新功能是:改变颜色、删除、新增、改变文字内容。其中新增是最简单的,只要加一个按钮即可,使用者便可以很直觉的按下这个按钮,最後看到新的便利贴出现在萤幕上。但是对於删除功能的话,对於使用者来说,必需要先选择要删除的便利贴是哪一个,才可以进行删除,而且对於改彦颜色、改变文字内容也是一样,所以看来我们得要有一个新的概念出现在便利贴中了:那就是选择状态。
除了选择状态,还要有选择状态触发之後的行动(删除、更改颜色等等),所以我们还要有一个选单来给使用者做操作,然後在没有选择状态时,使用者才可以新增便利贴,於是我们可以依照以上的这几点来写一个粗略的规格出来:
接下来我们就依据这些需求去设计 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]
}
BoardViewModel
,将它改为 EditorViewModel
,因为现在比较像是一个编辑器在做的事了,有选择状态,之後也会把改变颜色、删除、新增便利贴等等也放到这个 ViewModel 。首先来看到 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 了。
再帮大家复习一下之前 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
,他们的关系如下图所示:
再加上之前的 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
)
}
}
}
除了之前本来就有的 notes
跟 updateNotePosition
之外,这次新增了 [1] selectedNoteState 跟 [2] onNoteClicked 。selectedNoteState 被当作参数传进来之後,一样使用 delegate 的语法 - by 来取值,然後在 notes
的每一个回圈中检现在正在显示的这个便利贴是不是正在选择的那一个,也就是 [3] 的这个地方,如果刚好吻合的话 selected 的值就会是 true。
Optional 也可以使用
filter
? 是的你没看错,filter
不是只有 List 或是 Observable 的专利,事实上 Optional 还有更多的 operator 像是map
跟flatMap
。
// [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 来决定选择状态。
@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 的实作就相对单纯了:
selectedNote
的状态来决定的,如果没有任何便利贴被选了,就显示该按钮@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 长什麽样子吧!(请注意这影片是完成第二部後的样子喔!现在程序的实作还没做完!)
今天的内容是 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
昨天体验了一些快捷键和命令,今天要讲的是在 vim 中也很常用的搜寻与取代 搜寻与取代 vim 的搜...
这样的食材,才299吃到饱,别挑剔了啦~ 这家好好吃肉,就位在前几天分享过的「咕咕家」正对面。 好好...
今天在测试的时候发生了一个笑话, 发查询订单的request到丰收款的api结果一直回"验...
& 父选择器“&”通常与Sass的Nesting用法搭配使用, 当内层的选择器使用&...
在长尾效应跟爆款理论中,实际上是边际效应的正反两面。 网路减少了边际效应的成本,让原本的长尾需求被收...