Skip to content

Commit 66c5f82

Browse files
committed
Store values for DataStates for undo() function
Make defensive copies of listeners before forEach() loops, as a user may call removeListener() from machine callback. Add once argument to onEntry/onExit callbacks. Add return value to onTriggered() method in DSL transition builder.
1 parent 8a73326 commit 66c5f82

22 files changed

+448
-97
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Main features are:
3838
from event to state
3939
* [Parallel states](https://nsk90.github.io/kstatemachine/#parallel-states) to avoid a combinatorial explosion of
4040
states
41+
* [Undo transitions](https://nsk90.github.io/kstatemachine/#undo-transitions) for navigating back to previous state
4142
* [Argument](https://nsk90.github.io/kstatemachine/#arguments) passing for events and transitions
4243
* Supports [pending events](https://nsk90.github.io/kstatemachine/#pending-events)
4344
* [Export state machine](https://nsk90.github.io/kstatemachine/#export) structure

docs/index.md

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,19 @@ state {
103103
Or even shorter:
104104

105105
```kotlin
106-
state().onEntry { /*...*/ }
106+
state().onEntry { /* ... */ }
107107
```
108108

109+
`onEntry` and `onExit` DSL methods provide `once` argument. If it is set to `true` the listener will be removed after
110+
the first triggering.
111+
112+
```kotlin
113+
state().onEntry(once = true) { /* ... */ }
114+
```
115+
116+
_Note: it is safe to add and remove listeners from any machine callbacks, library protects its internal loops from such
117+
modifications._
118+
109119
### Listen group of states
110120

111121
If you need to perform some actions depending on active statuses of two or more states use `onActiveAllOf()`
@@ -164,14 +174,16 @@ createStateMachine {
164174
}
165175
```
166176

167-
### Targetless transitions
177+
### Target-less transitions
168178

169179
Transition may have no target state (`targetState` is null) which means that state machine stays in current state when
170-
such transition triggers:
180+
such transition triggers, it is useful to perform some actions without changing current state:
171181

172182
```kotlin
173183
greenState {
174-
transition<YellowEvent>()
184+
transition<YellowEvent> {
185+
onTriggered { /* ... */ }
186+
}
175187
}
176188
```
177189

@@ -268,12 +280,35 @@ There are two predefined event matchers:
268280

269281
You can define your own matchers by subclassing `EventMatcher` class.
270282

271-
## Undo transition
283+
## Undo transitions
284+
285+
Transitions may be undone with `StateMachine.undo()` function or alternatively by sending special `UndoEvent` to machine
286+
like this `machine.processEvent(UndoEvent)`. State Machine will roll back last transition which is usually is switching
287+
to previous state (except target-less transitions).
288+
This API might be called as many times as needed.
289+
To implement this feature library stores transitions in a stack, it takes memory,
290+
so this feature is disabled by default and must be enabled explicitly using `createStateMachine(enableUndo = true)`
291+
argument.
272292

273-
Transitions may be undone with `StateMachine::undo()` function or by sending special `UndoEvent` to machine
274-
like this `machine.processEvent(UndoEvent)`. State Machine will switch to previous state. To implement this feature
275-
library stores target states of transitions in a stack, it takes memory, so this feature is disabled by default and must
276-
be enabled explicitly using `createStateMachine(enableUndo = true)` argument.
293+
Undo functionality is implemented as `Event`, so it possible to call `undo()` from notification callbacks, if you use
294+
`QueuePendingEventHandler` (which is default) or its analog.
295+
296+
For example if states of state machine represent UI screens, `undo()` acts like some kind of `navigateUp()` function.
297+
298+
Internally every `UndoEvent` is transformed to `WrappedEvent` which stores original event and argument.
299+
When some state is entered as a result of undo operation you can access original event and argument with
300+
`unwrappedEvent` and `unwrappedArgument` extension properties of `TransitionParams` class.
301+
Original event is the event that triggered original transition to this state.
302+
303+
```kotlin
304+
state {
305+
onEntry { transitionParams -> // when called as result of undo() operation
306+
transitionParams.event // is WrappedEvent
307+
transitionParams.unwrappedEvent // is original event
308+
(transitionParams.event as WrappedEvent).event // same as using unwrappedEvent extension
309+
}
310+
}
311+
```
277312

278313
## Logging
279314

@@ -582,7 +617,7 @@ Use `exportToPlantUml()` extension function to export state machine
582617
to [PlantUML state diagram](https://plantuml.com/en/state-diagram).
583618

584619
```kotlin
585-
val machine = createStateMachine { /*...*/ }
620+
val machine = createStateMachine { /* ... */ }
586621
println(machine.exportToPlantUml())
587622
```
588623

kstatemachine/src/main/kotlin/ru/nsk/kstatemachine/BaseStateImpl.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ open class BaseStateImpl(override val name: String?, override val childMode: Chi
164164
if (initialState !is StateMachine) // inner state machine manages its internal state by its own
165165
initialState.recursiveEnterInitialStates(transitionParams)
166166
}
167+
167168
ChildMode.PARALLEL -> data.states.forEach {
168169
handleStateEntry(it, transitionParams)
169170
if (it !is StateMachine) // inner state machine manages its internal state by its own

kstatemachine/src/main/kotlin/ru/nsk/kstatemachine/DefaultState.kt

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import ru.nsk.kstatemachine.HistoryType.SHALLOW
77
open class DefaultState(name: String? = null, childMode: ChildMode = EXCLUSIVE) :
88
BaseStateImpl(name, childMode), State
99

10-
open class DefaultDataState<out D: Any>(
10+
open class DefaultDataState<out D : Any>(
1111
name: String? = null,
1212
override val defaultData: D? = null,
1313
childMode: ChildMode = EXCLUSIVE
@@ -24,22 +24,24 @@ open class DefaultDataState<out D: Any>(
2424
override fun onDoEnter(transitionParams: TransitionParams<*>) {
2525
if (this == transitionParams.direction.targetState) {
2626
when (val event = transitionParams.event) {
27-
is DataEvent<*> -> {
28-
@Suppress("UNCHECKED_CAST")
29-
event as DataEvent<D>
30-
with(event.data) {
31-
_data = this
32-
_lastData = this
33-
}
34-
}
35-
is IUndoEvent -> _data = lastData
27+
is DataEvent<*> -> assignEvent(event)
28+
is WrappedEvent -> assignEvent(event.event)
3629
else -> error("$event does not contain data required by $this")
3730
}
3831
} else { // implicit activation
3932
_data = lastData
4033
}
4134
}
4235

36+
private fun assignEvent(event: Event) {
37+
@Suppress("UNCHECKED_CAST")
38+
event as DataEvent<D>
39+
with(event.data) {
40+
_data = this
41+
_lastData = this
42+
}
43+
}
44+
4345
override fun onDoExit(transitionParams: TransitionParams<*>) {
4446
_data = null
4547
}
@@ -56,7 +58,7 @@ open class DefaultFinalState(name: String? = null) : DefaultState(name), FinalSt
5658
override fun <E : Event> addTransition(transition: Transition<E>) = super<FinalState>.addTransition(transition)
5759
}
5860

59-
open class DefaultFinalDataState<out D: Any>(name: String? = null, defaultData: D? = null) :
61+
open class DefaultFinalDataState<out D : Any>(name: String? = null, defaultData: D? = null) :
6062
DefaultDataState<D>(name, defaultData), FinalDataState<D> {
6163
override fun <E : Event> addTransition(transition: Transition<E>) = super<FinalDataState>.addTransition(transition)
6264
}

kstatemachine/src/main/kotlin/ru/nsk/kstatemachine/IState.kt

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ interface State : IState
7878
/**
7979
* State which holds data while it is active
8080
*/
81-
interface DataState<out D: Any> : IState {
81+
interface DataState<out D : Any> : IState {
8282
val defaultData: D?
8383

8484
/**
@@ -103,7 +103,7 @@ interface IFinalState : IState {
103103
}
104104

105105
interface FinalState : IFinalState, State
106-
interface FinalDataState<out D: Any> : IFinalState, DataState<D>
106+
interface FinalDataState<out D : Any> : IFinalState, DataState<D>
107107

108108
/**
109109
* Pseudo state is a state that machine passes automatically without explicit event. It cannot be active.
@@ -184,23 +184,32 @@ inline fun <reified S : IState> IState.requireState(recursive: Boolean = true) =
184184

185185
operator fun <S : IState> S.invoke(block: StateBlock<S>) = block()
186186

187-
fun <S : IState> S.onEntry(block: S.(TransitionParams<*>) -> Unit) {
187+
/**
188+
* Most common methods [onEntry] and [onExit] are shipped with [once] argument, to remove listener
189+
* after it is triggered the first time.
190+
* Looks that it is not necessary in other similar methods.
191+
*/
192+
fun <S : IState> S.onEntry(once: Boolean = false, block: S.(TransitionParams<*>) -> Unit) =
188193
addListener(object : IState.Listener {
189-
override fun onEntry(transitionParams: TransitionParams<*>) = block(transitionParams)
194+
override fun onEntry(transitionParams: TransitionParams<*>) {
195+
block(transitionParams)
196+
if (once) removeListener(this)
197+
}
190198
})
191-
}
192199

193-
fun <S : IState> S.onExit(block: S.(TransitionParams<*>) -> Unit) {
200+
/** See [onEntry] */
201+
fun <S : IState> S.onExit(once: Boolean = false, block: S.(TransitionParams<*>) -> Unit) =
194202
addListener(object : IState.Listener {
195-
override fun onExit(transitionParams: TransitionParams<*>) = block(transitionParams)
203+
override fun onExit(transitionParams: TransitionParams<*>) {
204+
block(transitionParams)
205+
if (once) removeListener(this)
206+
}
196207
})
197-
}
198208

199-
fun <S : IState> S.onFinished(block: S.(TransitionParams<*>) -> Unit) {
209+
fun <S : IState> S.onFinished(block: S.(TransitionParams<*>) -> Unit) =
200210
addListener(object : IState.Listener {
201211
override fun onFinished(transitionParams: TransitionParams<*>) = block(transitionParams)
202212
})
203-
}
204213

205214
/**
206215
* @param name is optional and is useful for getting state instance after state machine setup
@@ -212,7 +221,7 @@ fun IState.state(
212221
init: StateBlock<State>? = null
213222
) = addState(DefaultState(name, childMode), init)
214223

215-
fun <D: Any> IState.dataState(
224+
fun <D : Any> IState.dataState(
216225
name: String? = null,
217226
defaultData: D? = null,
218227
childMode: ChildMode = ChildMode.EXCLUSIVE,
@@ -231,7 +240,7 @@ fun IState.initialState(
231240
/**
232241
* @param defaultData is necessary for initial [DataState]
233242
*/
234-
fun <D: Any> IState.initialDataState(
243+
fun <D : Any> IState.initialDataState(
235244
name: String? = null,
236245
defaultData: D,
237246
childMode: ChildMode = ChildMode.EXCLUSIVE,
@@ -257,7 +266,7 @@ fun <S : IFinalState> IState.addFinalState(state: S, init: StateBlock<S>? = null
257266
fun IState.finalState(name: String? = null, init: StateBlock<FinalState>? = null) =
258267
addFinalState(DefaultFinalState(name), init)
259268

260-
fun <D: Any> IState.finalDataState(
269+
fun <D : Any> IState.finalDataState(
261270
name: String? = null,
262271
defaultData: D? = null,
263272
init: StateBlock<FinalDataState<D>>? = null

kstatemachine/src/main/kotlin/ru/nsk/kstatemachine/InternalState.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ abstract class InternalState : IState {
3030

3131
internal abstract fun recursiveExit(transitionParams: TransitionParams<*>)
3232
internal abstract fun recursiveStop()
33+
34+
/**
35+
* Called after each (including initial) transition completion.
36+
*/
3337
internal abstract fun recursiveAfterTransitionComplete(transitionParams: TransitionParams<*>)
3438
internal abstract fun cleanup()
3539
}
@@ -38,7 +42,7 @@ internal fun InternalState.requireParent() = requireNotNull(internalParent) { "$
3842

3943
internal fun InternalState.stateNotify(block: IState.Listener.() -> Unit) {
4044
val machine = machine as InternalStateMachine
41-
listeners.forEach { machine.runDelayingException { it.block() } }
45+
listeners.toList().forEach { machine.runDelayingException { it.block() } }
4246
}
4347

4448
internal fun <E : Event> InternalState.findTransitionsByEvent(event: E): List<InternalTransition<E>> {

kstatemachine/src/main/kotlin/ru/nsk/kstatemachine/InternalTransition.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@ interface InternalTransition<E : Event> : Transition<E> {
1010

1111
internal fun InternalTransition<*>.transitionNotify(block: Transition.Listener.() -> Unit) {
1212
val machine = sourceState.machine as InternalStateMachine
13-
listeners.forEach { machine.runDelayingException { it.block() } }
13+
listeners.toList().forEach { machine.runDelayingException { it.block() } }
1414
}

kstatemachine/src/main/kotlin/ru/nsk/kstatemachine/StateMachine.kt

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -108,41 +108,37 @@ interface StateMachine : State {
108108

109109
typealias StateMachineBlock = StateMachine.() -> Unit
110110

111-
fun StateMachine.onStarted(block: StateMachine.() -> Unit) {
111+
fun StateMachine.onStarted(block: StateMachine.() -> Unit) =
112112
addListener(object : StateMachine.Listener {
113113
override fun onStarted() = block()
114114
})
115-
}
116115

117-
fun StateMachine.onStopped(block: StateMachine.() -> Unit) {
116+
fun StateMachine.onStopped(block: StateMachine.() -> Unit) =
118117
addListener(object : StateMachine.Listener {
119118
override fun onStopped() = block()
120119
})
121-
}
122120

123-
fun StateMachine.onTransition(block: StateMachine.(TransitionParams<*>) -> Unit) {
121+
fun StateMachine.onTransition(block: StateMachine.(TransitionParams<*>) -> Unit) =
124122
addListener(object : StateMachine.Listener {
125123
override fun onTransition(transitionParams: TransitionParams<*>) =
126124
block(transitionParams)
127125
})
128-
}
129126

130-
fun StateMachine.onStateChanged(block: StateMachine.(newState: IState) -> Unit) {
127+
fun StateMachine.onStateChanged(block: StateMachine.(newState: IState) -> Unit) =
131128
addListener(object : StateMachine.Listener {
132129
override fun onStateChanged(newState: IState) = block(newState)
133130
})
134-
}
135131

136132
/**
137-
* Navigates machine to previous state.
133+
* Rolls back transition (usually it is navigating machine to previous state).
138134
* Previous states are stored in a stack, so this method mey be called multiple times if needed.
139-
* This function has same effect as calling processEvent(UndoEvent), but throws if undo feature is not enabled.
135+
* This function has same effect as alternative syntax processEvent(UndoEvent), but throws if undo feature is not enabled.
140136
*/
141-
fun StateMachine.undo() {
137+
fun StateMachine.undo(argument: Any? = null) {
142138
check(isUndoEnabled) {
143139
"Undo functionality is not enabled, use createStateMachine(isUndoEnabled = true) argument to enable it."
144140
}
145-
processEvent(UndoEvent)
141+
processEvent(UndoEvent, argument)
146142
}
147143

148144
/**

kstatemachine/src/main/kotlin/ru/nsk/kstatemachine/StateMachineImpl.kt

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ internal class StateMachineImpl(
2929
init {
3030
if (isUndoEnabled) {
3131
val undoState = addState(UndoState())
32-
transition<UndoEvent>("undo transition", undoState)
32+
transition<WrappedEvent>("undo transition", undoState)
3333
}
3434
}
3535

@@ -72,6 +72,7 @@ internal class StateMachineImpl(
7272
val transitionParams = makeStartTransitionParams(this, state, argument)
7373
runMachine(transitionParams)
7474
switchToTargetState(state as InternalState, this, transitionParams)
75+
recursiveAfterTransitionComplete(transitionParams)
7576
}
7677
}
7778
}
@@ -108,16 +109,27 @@ internal class StateMachineImpl(
108109
check(!isDestroyed) { "$this is already destroyed" }
109110
check(isRunning) { "$this is not started, call start() first" }
110111

112+
val eventAndArgument = wrapEvent(event, argument)
113+
111114
if (isProcessingEvent) {
112-
pendingEventHandler.onPendingEvent(event, argument)
115+
pendingEventHandler.onPendingEvent(eventAndArgument.event, eventAndArgument.argument)
113116
// pending event cannot be processed while previous event is still processing
114117
// even if PendingEventHandler does not throw. QueuePendingEventHandler implementation stores such events
115118
// to be processed later.
116119
return
117120
}
118121

119122
eventProcessingScope {
120-
process(EventAndArgument(event, argument))
123+
process(eventAndArgument)
124+
}
125+
}
126+
127+
private fun wrapEvent(event: Event, argument: Any?): EventAndArgument<*> {
128+
return if (isUndoEnabled && event is UndoEvent) {
129+
val wrapped = requireState<UndoState>().makeWrappedEvent()
130+
EventAndArgument(wrapped, argument)
131+
} else {
132+
EventAndArgument(event, argument)
121133
}
122134
}
123135

@@ -197,7 +209,7 @@ internal class StateMachineImpl(
197209
}
198210

199211
log {
200-
val targetText = if (targetState != null) "to $targetState" else "[targetless]"
212+
val targetText = if (targetState != null) "to $targetState" else "[target-less]"
201213
"${event::class.simpleName} triggers $transition from ${transition.sourceState} $targetText"
202214
}
203215

@@ -235,7 +247,7 @@ internal class StateMachineImpl(
235247
}
236248

237249
internal fun InternalStateMachine.machineNotify(block: StateMachine.Listener.() -> Unit) {
238-
machineListeners.forEach { runDelayingException { it.block() } }
250+
machineListeners.toList().forEach { runDelayingException { it.block() } }
239251
}
240252

241253
internal fun InternalStateMachine.runDelayingException(block: () -> Unit) =

0 commit comments

Comments
 (0)