はじめに
Swift+TCAで作成したアプリをKotlinに移植するため、GPTに作ってもらいました。基本的なところはできたつもりですが、まだ抜けがあるかもしれません。また私はKotlin初心者なので悪しからず。サンプルはこちらです。
使用例
ホーム画面(親)とカウンター画面(子)の構成の基本的なアプリ構成にしました。UIにはJetpack
を使ってます。
MainActivity
MainActivity.kt
package com.example.kotlin_tca
import HomeView
import com.example.kotlin_tca.core.tca.*
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import com.example.kotlin_tca.core.theme.AppTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val store = Store<HomeFeature.State, HomeFeature.Action>(
initialState = HomeFeature.State(),
reducer = HomeFeature,
)
setContent {
AppTheme {
Surface(
color = MaterialTheme.colorScheme.background
) {
HomeView(store = store)
}
}
}
}
}
ホーム画面
Home.kt
import com.example.kotlin_tca.core.tca.*
import com.example.kotlin_tca.counter.CounterFeature
import com.example.kotlin_tca.counter.CounterView
import androidx.compose.runtime.*
import androidx.compose.foundation.layout.*
import androidx.compose.ui.*
import androidx.compose.material3.*
object HomeFeature : ReducerOf<HomeFeature.State, HomeFeature.Action> {
data class State(
val title: String = "Home",
val counter: CounterFeature.State? = null
)
sealed class Action {
data class SetTitle(val title: String) : Action()
class CounterButtonTapped() : Action()
data class Counter(val action: CounterFeature.Action) : Action()
}
//WritableKeyPathの代わり
val counterLens = Lens<HomeFeature.State, CounterFeature.State?>(
get = { it.counter },
set = { parent, child -> parent.copy(counter = child) }
)
val counterPrism = Prism<HomeFeature.Action, CounterFeature.Action>(
extract = { (it as? Action.Counter)?.action },
embed = { Action.Counter(it) }
)
override fun body(): ReducerOf<State, Action> =
Reduce<State, Action>{ state, action ->
when (action) {
is Action.SetTitle -> state.copy(title = action.title) to Effect.none()
is Action.CounterButtonTapped->{
state.copy(counter = CounterFeature.State(1)) to Effect.none()
}
is Action.Counter -> {
when (val innerAction = action.action) {
is CounterFeature.Action.DismissTapped -> {
state.copy(counter = null) to Effect.none()
}
else -> state to Effect.none()
}
}
}
} +
OptionalScope(
stateLens = counterLens,
actionPrism = counterPrism,
reducer = CounterFeature
)
}
@Composable
fun HomeView(store: StoreOf<HomeFeature.State, HomeFeature.Action>) {
val state by store.state.collectAsState()
Navigation(
store = store,
lens = HomeFeature.counterLens,
prism = HomeFeature.counterPrism,
child = { store ->
CounterView(store)
}
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(state.title)
Button(onClick = {
store.send(HomeFeature.Action.CounterButtonTapped())
}) {
Text("Go to Counter")
}
Button(onClick = {
store.send(HomeFeature.Action.SetTitle(state.title))
}) {
Text("Title ")
}
}
}
}
WritableKeyPath(\.counter
)が実装できなかったのでsetter
,getter
のペアを自作する必要があります。Navigation
でtree-based-navigation(状態のnull/nonnullで遷移)を実現してます。遷移のアニメーションとかはなくただ表示を切り替えてるだけなので今後要改善。
Counter
Counter.kt
package com.example.kotlin_tca.counter
import com.example.kotlin_tca.core.tca.*
import kotlinx.coroutines.delay
import androidx.compose.runtime.*
import androidx.compose.foundation.layout.*
import androidx.compose.ui.*
import androidx.compose.material3.*
object CounterFeature : ReducerOf<CounterFeature.State, CounterFeature.Action> {
data class State(val count: Int = 0)
sealed class Action {
object IncrementTapped : Action()
object DecrementTapped : Action()
object DismissTapped : Action()
}
override fun body(): ReducerOf<State, Action> =
Reduce { state, action ->
when (action) {
is Action.IncrementTapped -> state.copy(count = state.count + 1) to Effect.none()
is Action.DecrementTapped -> state.copy(count = state.count - 1) to Effect.none()
is Action.DismissTapped -> state to Effect.none()
}
}
}
@Composable
fun CounterView(store: StoreOf<CounterFeature.State, CounterFeature.Action>) {
val state by store.state.collectAsState()
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Count: ${state.count}")
Row {
Button(onClick = { store.send(CounterFeature.Action.DecrementTapped) }) {
Text("-")
}
Button(onClick = { store.send(CounterFeature.Action.IncrementTapped) }) {
Text("+")
}
}
Button(onClick = { store.send(CounterFeature.Action.DismissTapped) }) {
Text("Dismiss")
}
}
}
ライブラリ
正式なライブラリにはしてないのでコピペして使ってください。
TCA.kt
interface ReducerOf<State, Action> {
fun body(): ReducerOf<State, Action>
fun reduce(state: State, action: Action): Pair<State, Effect<Action>> {
return body().reduce(state, action)
}
}
fun <S, A> Reduce(block: (S, A) -> Pair<S, Effect<A>>): ReducerOf<S, A> {
return object: ReducerOf<S, A> {
override fun body() = this
override fun reduce(state: S, action: A) = block(state, action)
}
}
fun <ParentState, ParentAction, ChildState, ChildAction> Scope(
stateLens: Lens<ParentState, ChildState>,
actionPrism: Prism<ParentAction, ChildAction>,
reducer: ReducerOf<ChildState, ChildAction>
): ReducerOf<ParentState, ParentAction> {
return object : ReducerOf<ParentState, ParentAction> {
override fun body() = this
override fun reduce(parentState: ParentState, parentAction: ParentAction): Pair<ParentState, Effect<ParentAction>> {
val childAction = actionPrism.extract(parentAction) ?: return parentState to Effect.none()
val childState = stateLens.get(parentState)
val (newChildState, childEffect) = reducer.reduce(childState, childAction)
val newParentState = stateLens.set(parentState, newChildState)
val newParentEffect = childEffect.map { actionPrism.embed(it) }
return newParentState to newParentEffect
}
}
}
fun <ParentState, ParentAction, ChildState, ChildAction> OptionalScope(
stateLens: Lens<ParentState, ChildState?>,
actionPrism: Prism<ParentAction, ChildAction>,
reducer: ReducerOf<ChildState, ChildAction>
): ReducerOf<ParentState, ParentAction> {
return object : ReducerOf<ParentState, ParentAction> {
override fun body() = this
override fun reduce(parentState: ParentState, parentAction: ParentAction): Pair<ParentState, Effect<ParentAction>> {
val childAction = actionPrism.extract(parentAction) ?: return parentState to Effect.none()
val childState = stateLens.get(parentState) ?: return parentState to Effect.none()
val (newChildState, childEffect) = reducer.reduce(childState, childAction)
val newParentState = stateLens.set(parentState, newChildState)
val newParentEffect = childEffect.map { actionPrism.embed(it) }
return newParentState to newParentEffect
}
}
}
operator fun <S, A> ReducerOf<S, A>.plus(
other: ReducerOf<S, A>
): ReducerOf<S, A> {
return object : ReducerOf<S, A> {
override fun body() = this
override fun reduce(state: S, action: A): Pair<S, Effect<A>> {
val (s1, e1) = this@plus.reduce(state, action)
val (s2, e2) = other.reduce(s1, action)
return s2 to Effect.merge(e1, e2)
}
}
}
interface StoreOf<State, Action> {
val state: StateFlow<State>
fun send(action: Action)
fun <ChildState, ChildAction>scope(
lens: Lens<State, ChildState?>,
prism: Prism<Action, ChildAction>,
): ScopedStore<ChildState, ChildAction>?{
val current = state.value
val childState = lens.get(current) ?: return null
val childStateFlow = state
.mapNotNull { lens.get(it) }
.distinctUntilChanged()
.stateIn(
CoroutineScope(Dispatchers.Main),
SharingStarted.Eagerly,
initialValue = childState
)
return ScopedStore<ChildState, ChildAction>(
state = childStateFlow,
sendAction = { childAction ->
this.send(prism.embed(childAction))
}
)
}
}
class Store<State, Action>(
initialState: State,
private val reducer: ReducerOf<State, Action>,
) : StoreOf<State, Action> {
private val _state = MutableStateFlow(initialState)
override val state: StateFlow<State> = _state
private val coroutineScope = CoroutineScope(Dispatchers.Main)
override fun send(action: Action) {
val (newState, effect) = reducer.reduce(_state.value, action)
_state.value = newState
coroutineScope.launch {
effect.collect { newAction ->
send(newAction)
}
}
}
}
class ScopedStore<ChildState, ChildAction>(
override val state: StateFlow<ChildState>,
private val sendAction: (ChildAction) -> Unit
) : StoreOf<ChildState, ChildAction> {
override fun send(action: ChildAction) {
sendAction(action)
}
}
class Effect<A> private constructor(
private val flow: Flow<A>
) {
fun <B> map(transform: (A) -> B): Effect<B> =
Effect(flow.map(transform))
suspend fun collect(collector: suspend (A) -> Unit) = flow.collect(collector)
companion object {
fun <A> none(): Effect<A> = Effect(emptyFlow())
fun <A> just(value: A): Effect<A> = Effect(flowOf(value))
fun <A> merge(vararg effects: Effect<A>): Effect<A> =
Effect(merge(*effects.map { it.flow }.toTypedArray()))
fun <A> run(
dispatcher: CoroutineDispatcher = Dispatchers.Default,
block: suspend (suspend (A) -> Unit) -> Unit
): Effect<A> {
val flow = flow {
coroutineScope {
block { action -> emit(action) }
}
}.flowOn(dispatcher)
return Effect(flow)
}
}
}
class Lens<S, A>(
val get: (S) -> A,
val set: (S, A) -> S
) {
fun <B> compose(other: Lens<A, B>): Lens<S, B> = Lens(
get = { s -> other.get(this.get(s)) },
set = { s, b -> this.set(s, other.set(this.get(s), b)) }
)
}
class Prism<ParentAction, ChildAction>(
val extract: (ParentAction) -> ChildAction?,
val embed: (ChildAction) -> ParentAction
)
@Composable
fun <ParentState, ParentAction, ChildState, ChildAction> Navigation(
store: StoreOf<ParentState, ParentAction>,
lens: Lens<ParentState, ChildState?>,
prism: Prism<ParentAction, ChildAction>,
child: @Composable (StoreOf<ChildState, ChildAction>) -> Unit,
parent: @Composable () -> Unit
) {
val parentState by store.state.collectAsState()
val childState = lens.get(parentState)
val childStore = remember(childState) {
store.scope(lens = lens, prism = prism)
}
if (childStore != null) {
child(childStore)
} else {
parent()
}
}
おわりに
進捗があったら追記していきます。単方向データフローはいいぞ。