In my Kotlin desktop application using TornadoFX, I have created an AudioCard
layout (subclass of VBox
) which has a few labels and basic audio player controls. This AudioCard
has an AudioCardViewModel
which handles events from the UI and an AudioCardModel
which holds information like the title, subtitle, audio file path, etc. A simplified version is shown below.
data class AudioCardModel(
var title: String,
var audioFile: File
)
class AudioCardViewModel(title: String, audioFile: File) {
val model = AudioCardModel(title, audioFile)
var titleProperty = SimpleStringProperty(model.title)
fun playButtonPressed() {
// play the audio file from the model
}
}
class AudioCard(title: String, audioFile: File) : VBox() {
val viewModel = AudioCardViewModel(title, audioFile)
init {
// create the UI
label(title) {
bind(viewModel.titleProperty)
}
button("Play") {
viewModel.playButtonPressed()
}
}
}
Up until this point, I have tried to keep the code as general as possible, allowing myself or others to reuse this UI component in future applications that need to play audio. However, for my current application, it makes the most sense to have a more specialized version of this UI component that initializes itself directly from my data model class and can extend some of the actions. I've tried something like this (the required fields and classes from the previous code block were switched to open
):
data class CustomAudioCardModel(
var customData: CustomData
)
class CustomAudioCardViewModel(customData: CustomData)
: AudioCardViewModel(customData.name, customData.file) {
val model = CustomAudioCardModel(customData)
override fun playButtonPressed() {
super.playButtonPressed()
// do secondary things only needed by CustomAudioCardViewModel
}
}
class CustomAudioCard(customData: CustomData): AudioCard(customData.name, customData.file) {
override val viewModel = CustomAudioCardViewModel(customData)
}
Unfortunately, this isn't so straightforward. By overriding viewModel
in CustomAudioCard
, the viewModel
property ceases to be final, causing a NullPointerException when the init function of the AudioCard
superclass tries to use the view model to set up the title label before the child class has initialized the view model.
I suspect there might be a way out of this by defining an AudioCardViewModel
interface and/or using Kotlin's ability to delegate with the by
keyword, but I'm under the impression that defining the interface (like in MVP) shouldn't be necessary for MVVM.
To summarize: What is the correct way to extend an existing MVVM control, specifically in the context of the Kotlin TornadoFX library?
Here is the solution I came across from Paul Stovell. Instead of creating the view model within the view (Option 1 in Stovell's article), I switched to injecting the view model into the view (Option 2). I also refactored for better MVVM adherence with help from the TornadoFX documentation and this answer regarding where business logic should go. My AudioCard code now looks like this:
open class AudioCardModel(title: String, audioFile: File) {
var title: String by property(title)
val titleProperty = getProperty(AudioCardModel::title)
var audioFile: File by property(audioFile)
val audioFileProperty = getProperty(AudioCardModel::audioFile)
open fun play() {
// play the audio file
}
}
open class AudioCardViewModel(private val model: AudioCardModel) {
var titleProperty = bind { model.titleProperty }
fun playButtonPressed() {
model.play()
}
}
open class AudioCard(private val viewModel: AudioCardViewModel) : VBox() {
init {
// create the UI
label(viewModel.titleProperty.get()) {
bind(viewModel.titleProperty)
}
button("Play") {
viewModel.playButtonPressed()
}
}
}
The extension view now looks like:
class CustomAudioCardModel(
var customData: CustomData
) : AudioCardModel(customData.name, customData.file) {
var didPlay by property(false)
val didPlayProperty = getProperty(CustomAudioCardModel::didPlay)
override fun play() {
super.play()
// do extra business logic
didPlay = true
}
}
class CustomAudioCardViewModel(
private val model: CustomAudioCardModel
) : AudioCardViewModel(model) {
val didPlayProperty = bind { model.didPlayProperty }
}
class CustomAudioCard(
private val viewModel: CustomAudioCardViewModel
) : AudioCard(customViewModel) {
init {
model.didPlayProperty.onChange { newValue ->
// change UI when audio has been played
}
}
}
I see a few ways to clean this up, especially regarding the models, but this option seems to work well in my scenario.