Skip to content

Commit

Permalink
polish ViewControllerBasedLifecycleOwner (#1248)
Browse files Browse the repository at this point in the history
## Proposed Changes

Fixed iOS-related comments from #1198

## Testing

Test: added ViewControllerBasedLifecycleOwnerTest
  • Loading branch information
kropp authored Apr 18, 2024
1 parent fb784f7 commit 9f63260
Show file tree
Hide file tree
Showing 3 changed files with 110 additions and 175 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,16 @@ import platform.UIKit.UIApplicationWillEnterForegroundNotification
import platform.darwin.NSObject

internal class ApplicationStateListener(
/**
* [NSNotificationCenter] to listen to, can be customized for tests purposes
*/
private val notificationCenter: NSNotificationCenter = NSNotificationCenter.defaultCenter,
/**
* Callback which will be called with `true` when the app becomes active, and `false` when the app goes background
*/
private val callback: (Boolean) -> Unit
private val onApplicationActiveStateChanged: (Boolean) -> Unit
) : NSObject() {
init {
val notificationCenter = NSNotificationCenter.defaultCenter

notificationCenter.addObserver(
this,
NSSelectorFromString(::applicationWillEnterForeground.name),
Expand All @@ -51,20 +53,18 @@ internal class ApplicationStateListener(

@ObjCAction
fun applicationWillEnterForeground() {
callback(true)
onApplicationActiveStateChanged(true)
}

@ObjCAction
fun applicationDidEnterBackground() {
callback(false)
onApplicationActiveStateChanged(false)
}

/**
* Deregister from [NSNotificationCenter]
*/
fun dispose() {
val notificationCenter = NSNotificationCenter.defaultCenter

notificationCenter.removeObserver(this, UIApplicationWillEnterForegroundNotification, null)
notificationCenter.removeObserver(this, UIApplicationDidEnterBackgroundNotification, null)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,195 +16,51 @@

package androidx.compose.ui.window

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Lifecycle.State
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import kotlin.test.fail

internal class ViewControllerBasedLifecycleOwner : LifecycleOwner {
private enum class Action {
VIEW_WILL_APPEAR,
VIEW_DID_DISAPPEAR,
APPLICATION_DID_ENTER_BACKGROUND,
APPLICATION_WILL_ENTER_FOREGROUND,
DISPOSE

// TODO: add actions for Popup and Dialog to behave like Android
}

private sealed interface State {
fun reduce(action: Action): State

class Created(
private val isApplicationForeground: Boolean,
private val lifecycle: LifecycleRegistry
) : State {
override fun reduce(action: Action): State {
return when (action) {
Action.VIEW_WILL_APPEAR -> {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START)

if (isApplicationForeground) {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
Running(lifecycle = lifecycle)
} else {
Suspended(lifecycle = lifecycle)
}
}

Action.VIEW_DID_DISAPPEAR -> {
if (isApplicationForeground) {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
}

lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP)

Created(isApplicationForeground = isApplicationForeground, lifecycle = lifecycle)
}

Action.APPLICATION_DID_ENTER_BACKGROUND -> {
Created(isApplicationForeground = false, lifecycle = lifecycle)
}

Action.APPLICATION_WILL_ENTER_FOREGROUND -> {
Created(isApplicationForeground = true, lifecycle = lifecycle)
}

Action.DISPOSE -> {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)

Disposed
}
}
}
}
class Running(
private val lifecycle: LifecycleRegistry
) : State {
override fun reduce(action: Action): State {
return when (action) {
Action.VIEW_WILL_APPEAR -> {
this
}

Action.VIEW_DID_DISAPPEAR -> {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP)

Created(isApplicationForeground = true, lifecycle = lifecycle)
}

Action.APPLICATION_DID_ENTER_BACKGROUND -> {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)

Suspended(lifecycle = lifecycle)
}

Action.APPLICATION_WILL_ENTER_FOREGROUND -> {
this
}

Action.DISPOSE -> {
logWarning("'ViewControllerBasedLifecycleOwner' received 'Action.DISPOSE' while in 'State.Running'. Make sure that view controller containment API is used correctly. 'removeFromParent' must be called before 'dispose'")

lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)

Disposed
}
}
}
}

class Suspended(private val lifecycle: LifecycleRegistry) : State {
override fun reduce(action: Action): State {
return when(action) {
Action.VIEW_WILL_APPEAR -> {
this
}

Action.VIEW_DID_DISAPPEAR -> {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP)

Created(isApplicationForeground = false, lifecycle = lifecycle)
}

Action.APPLICATION_DID_ENTER_BACKGROUND -> {
this
}

Action.APPLICATION_WILL_ENTER_FOREGROUND -> {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)

Running(lifecycle = lifecycle)
}

Action.DISPOSE -> {
logWarning("'ViewControllerBasedLifecycleOwner' received 'Action.DISPOSE' while in 'State.Suspended'. Make sure that view controller containment API is used correctly. 'removeFromParent' must be called before 'dispose'")

lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)

Disposed
}
}
}
}

object Disposed : State {
override fun reduce(action: Action): State {
when (action) {
Action.VIEW_WILL_APPEAR, Action.VIEW_DID_DISAPPEAR, Action.DISPOSE -> {
fail("Invalid '$action' for 'State.Disposed'")
}
Action.APPLICATION_DID_ENTER_BACKGROUND, Action.APPLICATION_WILL_ENTER_FOREGROUND -> {
// no-op
return this
}
}
}
}
}
import platform.Foundation.NSNotificationCenter

internal class ViewControllerBasedLifecycleOwner(
notificationCenter: NSNotificationCenter = NSNotificationCenter.defaultCenter,
) : LifecycleOwner {
override val lifecycle = LifecycleRegistry(this)

private var state: State = State.Created(
isApplicationForeground = ApplicationStateListener.isApplicationActive,
lifecycle = lifecycle
)
private var isViewAppeared = false
private var isAppForeground = ApplicationStateListener.isApplicationActive
private var isDisposed = false

private val applicationStateListener = ApplicationStateListener { isForeground ->
handleAction(
if (isForeground) Action.APPLICATION_WILL_ENTER_FOREGROUND
else Action.APPLICATION_DID_ENTER_BACKGROUND
)
private val applicationStateListener = ApplicationStateListener(notificationCenter) { isForeground ->
isAppForeground = isForeground
updateLifecycleState()
}

init {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
updateLifecycleState()
}

fun dispose() {
handleAction(Action.DISPOSE)
applicationStateListener.dispose()
isDisposed = true
updateLifecycleState()
}

fun handleViewWillAppear() {
handleAction(Action.VIEW_WILL_APPEAR)
isViewAppeared = true
updateLifecycleState()
}

fun handleViewDidDisappear() {
handleAction(Action.VIEW_DID_DISAPPEAR)
}

private fun handleAction(action: Action) {
state = state.reduce(action)
isViewAppeared = false
updateLifecycleState()
}

companion object {
fun logWarning(message: String) {
println("Warning: ViewControllerBasedLifecycleOwner - $message")
private fun updateLifecycleState() {
lifecycle.currentState = when {
isDisposed -> State.DESTROYED
isViewAppeared && isAppForeground -> State.RESUMED
isViewAppeared -> State.STARTED
else -> State.CREATED
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.ui.window

import androidx.lifecycle.Lifecycle
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import platform.Foundation.NSNotificationCenter
import platform.UIKit.UIApplication
import platform.UIKit.UIApplicationDidEnterBackgroundNotification
import platform.UIKit.UIApplicationWillEnterForegroundNotification

class ViewControllerBasedLifecycleOwnerTest {
@Test
fun allEvents() {
val notificationCenter = NSNotificationCenter()
val lifecycleOwner = ViewControllerBasedLifecycleOwner(notificationCenter)
assertEquals(Lifecycle.State.CREATED, lifecycleOwner.lifecycle.currentState)

lifecycleOwner.handleViewWillAppear()
assertEquals(Lifecycle.State.RESUMED, lifecycleOwner.lifecycle.currentState)

notificationCenter.postNotificationName(UIApplicationDidEnterBackgroundNotification, UIApplication.sharedApplication)
assertEquals(Lifecycle.State.STARTED, lifecycleOwner.lifecycle.currentState)

notificationCenter.postNotificationName(UIApplicationWillEnterForegroundNotification, UIApplication.sharedApplication)
assertEquals(Lifecycle.State.RESUMED, lifecycleOwner.lifecycle.currentState)

lifecycleOwner.handleViewDidDisappear()
assertEquals(Lifecycle.State.CREATED, lifecycleOwner.lifecycle.currentState)

lifecycleOwner.dispose()
assertEquals(Lifecycle.State.DESTROYED, lifecycleOwner.lifecycle.currentState)
}

@Test
fun foregroundThenViewWillAppear() {
val notificationCenter = NSNotificationCenter()
val lifecycleOwner = ViewControllerBasedLifecycleOwner(notificationCenter)

notificationCenter.postNotificationName(UIApplicationWillEnterForegroundNotification, UIApplication.sharedApplication)
assertEquals(Lifecycle.State.CREATED, lifecycleOwner.lifecycle.currentState)

lifecycleOwner.handleViewWillAppear()
assertEquals(Lifecycle.State.RESUMED, lifecycleOwner.lifecycle.currentState)
}

@Test
fun viewDidDisappearThenBackground() {
val notificationCenter = NSNotificationCenter()
val lifecycleOwner = ViewControllerBasedLifecycleOwner(notificationCenter)
lifecycleOwner.handleViewWillAppear()

notificationCenter.postNotificationName(UIApplicationWillEnterForegroundNotification, UIApplication.sharedApplication)
assertEquals(Lifecycle.State.RESUMED, lifecycleOwner.lifecycle.currentState)

lifecycleOwner.handleViewDidDisappear()
assertEquals(Lifecycle.State.CREATED, lifecycleOwner.lifecycle.currentState)

// this should not happen, but let's protect against it anyway
notificationCenter.postNotificationName(UIApplicationDidEnterBackgroundNotification, UIApplication.sharedApplication)
assertEquals(Lifecycle.State.CREATED, lifecycleOwner.lifecycle.currentState)
}
}

0 comments on commit 9f63260

Please sign in to comment.