Skip to content

Commit a372be5

Browse files
authored
Merge pull request #49 from nsk90/undo
Implement Undo functionality
2 parents b1870a1 + 66c5f82 commit a372be5

22 files changed

+488
-94
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: 49 additions & 6 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,6 +280,36 @@ There are two predefined event matchers:
268280

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

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.
292+
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+
```
312+
271313
## Logging
272314

273315
You can enable internal state machine logging on your platform.
@@ -365,7 +407,8 @@ the state hierarchy as the source state.
365407
`StateMachine` is a subclass of `IState`, this allows to use it as a child of another state machine like a simple state.
366408
The parent state machine treats the child machine as an atomic state. It is not possible to reference states of a child
367409
machine from parent transitions and vise versa. Child machine is automatically started when parent enters it. Events
368-
from parent machine are not passed to it child machines. Child machine receives events only from its own `processEvent()`
410+
from parent machine are not passed to it child machines. Child machine receives events only from its
411+
own `processEvent()`
369412
calls.
370413

371414
## Parallel states
@@ -574,7 +617,7 @@ Use `exportToPlantUml()` extension function to export state machine
574617
to [PlantUML state diagram](https://plantuml.com/en/state-diagram).
575618

576619
```kotlin
577-
val machine = createStateMachine { /*...*/ }
620+
val machine = createStateMachine { /* ... */ }
578621
println(machine.exportToPlantUml())
579622
```
580623

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: 16 additions & 9 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>(
10+
open class DefaultDataState<out D : Any>(
1111
name: String? = null,
1212
override val defaultData: D? = null,
1313
childMode: ChildMode = EXCLUSIVE
@@ -22,19 +22,26 @@ open class DefaultDataState<out D>(
2222
}
2323

2424
override fun onDoEnter(transitionParams: TransitionParams<*>) {
25-
if (this == transitionParams.direction.targetState && transitionParams.event !is UndoEvent) {
26-
@Suppress("UNCHECKED_CAST")
27-
val event = transitionParams.event as? DataEvent<D>
28-
?: error("${transitionParams.event} does not contain data required by $this")
29-
with(event.data) {
30-
_data = this
31-
_lastData = this
25+
if (this == transitionParams.direction.targetState) {
26+
when (val event = transitionParams.event) {
27+
is DataEvent<*> -> assignEvent(event)
28+
is WrappedEvent -> assignEvent(event.event)
29+
else -> error("$event does not contain data required by $this")
3230
}
3331
} else { // implicit activation
3432
_data = lastData
3533
}
3634
}
3735

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+
3845
override fun onDoExit(transitionParams: TransitionParams<*>) {
3946
_data = null
4047
}
@@ -51,7 +58,7 @@ open class DefaultFinalState(name: String? = null) : DefaultState(name), FinalSt
5158
override fun <E : Event> addTransition(transition: Transition<E>) = super<FinalState>.addTransition(transition)
5259
}
5360

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

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

Lines changed: 32 additions & 14 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> : 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> : 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> IState.dataState(
224+
fun <D : Any> IState.dataState(
216225
name: String? = null,
217226
defaultData: D? = null,
218227
childMode: ChildMode = ChildMode.EXCLUSIVE,
@@ -228,6 +237,16 @@ fun IState.initialState(
228237
init: StateBlock<State>? = null
229238
) = addInitialState(DefaultState(name, childMode), init)
230239

240+
/**
241+
* @param defaultData is necessary for initial [DataState]
242+
*/
243+
fun <D : Any> IState.initialDataState(
244+
name: String? = null,
245+
defaultData: D,
246+
childMode: ChildMode = ChildMode.EXCLUSIVE,
247+
init: StateBlock<DataState<D>>? = null
248+
) = addInitialState(DefaultDataState(name, defaultData, childMode), init)
249+
231250
/**
232251
* A shortcut for [IState.addState] and [IState.setInitialState] calls
233252
*/
@@ -247,7 +266,7 @@ fun <S : IFinalState> IState.addFinalState(state: S, init: StateBlock<S>? = null
247266
fun IState.finalState(name: String? = null, init: StateBlock<FinalState>? = null) =
248267
addFinalState(DefaultFinalState(name), init)
249268

250-
fun <D> IState.finalDataState(
269+
fun <D : Any> IState.finalDataState(
251270
name: String? = null,
252271
defaultData: D? = null,
253272
init: StateBlock<FinalDataState<D>>? = null
@@ -260,5 +279,4 @@ fun IState.historyState(
260279
name: String? = null,
261280
defaultState: IState? = null,
262281
historyType: HistoryType = HistoryType.SHALLOW
263-
) =
264-
addState(DefaultHistoryState(name, defaultState, historyType))
282+
) = addState(DefaultHistoryState(name, defaultState, historyType))

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: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,6 @@ interface StateMachine : State {
5252
*/
5353
fun processEvent(event: Event, argument: Any? = null)
5454

55-
/**
56-
* Navigates machine to previous state.
57-
* Previous states are stored in a stack, so this method mey be called multiple times if needed.
58-
*/
59-
fun undo()
60-
6155
/**
6256
* Destroys machine structure clearing all listeners, states etc.
6357
*/
@@ -114,29 +108,37 @@ interface StateMachine : State {
114108

115109
typealias StateMachineBlock = StateMachine.() -> Unit
116110

117-
fun StateMachine.onStarted(block: StateMachine.() -> Unit) {
111+
fun StateMachine.onStarted(block: StateMachine.() -> Unit) =
118112
addListener(object : StateMachine.Listener {
119113
override fun onStarted() = block()
120114
})
121-
}
122115

123-
fun StateMachine.onStopped(block: StateMachine.() -> Unit) {
116+
fun StateMachine.onStopped(block: StateMachine.() -> Unit) =
124117
addListener(object : StateMachine.Listener {
125118
override fun onStopped() = block()
126119
})
127-
}
128120

129-
fun StateMachine.onTransition(block: StateMachine.(TransitionParams<*>) -> Unit) {
121+
fun StateMachine.onTransition(block: StateMachine.(TransitionParams<*>) -> Unit) =
130122
addListener(object : StateMachine.Listener {
131123
override fun onTransition(transitionParams: TransitionParams<*>) =
132124
block(transitionParams)
133125
})
134-
}
135126

136-
fun StateMachine.onStateChanged(block: StateMachine.(newState: IState) -> Unit) {
127+
fun StateMachine.onStateChanged(block: StateMachine.(newState: IState) -> Unit) =
137128
addListener(object : StateMachine.Listener {
138129
override fun onStateChanged(newState: IState) = block(newState)
139130
})
131+
132+
/**
133+
* Rolls back transition (usually it is navigating machine to previous state).
134+
* Previous states are stored in a stack, so this method mey be called multiple times if needed.
135+
* This function has same effect as alternative syntax processEvent(UndoEvent), but throws if undo feature is not enabled.
136+
*/
137+
fun StateMachine.undo(argument: Any? = null) {
138+
check(isUndoEnabled) {
139+
"Undo functionality is not enabled, use createStateMachine(isUndoEnabled = true) argument to enable it."
140+
}
141+
processEvent(UndoEvent, argument)
140142
}
141143

142144
/**

0 commit comments

Comments
 (0)