Skip to content

Commit

Permalink
Add DisplayLinkClock for iOS, tvOS, and macOS (#170)
Browse files Browse the repository at this point in the history
* Add DisplayLinkClock for iOS, tvOS, and macOS

This commit adds two imlementations of a `MonotonicFrameClock`
backed by `CADisplayLink` on iOS and tvOS and `CVDisplayLink`
on macOS.

watchOS does not have an equivalent that I'm aware of and therefore
has been omitted.

* Simplify build configuration

---------

Co-authored-by: Jake Wharton <jw@squareup.com>
  • Loading branch information
kevincianfarini and JakeWharton committed May 23, 2023
1 parent 4bc4044 commit 7b6bb17
Show file tree
Hide file tree
Showing 5 changed files with 192 additions and 0 deletions.
25 changes: 25 additions & 0 deletions molecule-runtime/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,31 @@ kotlin {
implementation libs.androidx.core
}
}

darwinMain {
dependsOn(commonMain)
}
darwinTest {
dependsOn(commonTest)
}

quartzCoreMain {
dependsOn(darwinMain)
}
macosMain {
dependsOn(darwinMain)
}
}

targets.each { target ->
if (target.name.startsWith('ios') || target.name.startsWith('tvos')) {
target.compilations.main.defaultSourceSet.dependsOn(sourceSets.quartzCoreMain)
// TODO Link against XCTest in order to get frame pulses on iOS/tvOS.
// target.compilations.test.defaultSourceSet.dependsOn(sourceSets.darwinTest)
} else if (target.name.startsWith('macos')) {
target.compilations.main.defaultSourceSet.dependsOn(sourceSets.macosMain)
target.compilations.test.defaultSourceSet.dependsOn(sourceSets.darwinTest)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* Copyright (C) 2023 Square, Inc.
*
* 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 app.cash.molecule

import androidx.compose.runtime.MonotonicFrameClock

public expect object DisplayLinkClock : MonotonicFrameClock
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright (C) 2023 Square, Inc.
*
* 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 app.cash.molecule

import kotlin.test.Test
import kotlinx.coroutines.test.runTest

class DisplayLinkClockTest {

@Test fun `DisplayLinkClock delivers a single frame`() = runTest {
DisplayLinkClock.withFrameNanos {
// If this function does not time out the test passes.
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright (C) 2023 Square, Inc.
*
* 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 app.cash.molecule

import androidx.compose.runtime.BroadcastFrameClock
import androidx.compose.runtime.MonotonicFrameClock
import kotlinx.cinterop.alloc
import kotlinx.cinterop.nativeHeap
import kotlinx.cinterop.ptr
import kotlinx.cinterop.staticCFunction
import kotlinx.cinterop.value
import platform.CoreVideo.CVDisplayLinkCreateWithActiveCGDisplays
import platform.CoreVideo.CVDisplayLinkRefVar
import platform.CoreVideo.CVDisplayLinkSetOutputCallback
import platform.CoreVideo.CVDisplayLinkStart
import platform.CoreVideo.CVDisplayLinkStop
import platform.CoreVideo.kCVReturnSuccess

public actual object DisplayLinkClock : MonotonicFrameClock {

private val clock = BroadcastFrameClock {
// One or more awaiters have appeared. Start the DisplayLink clock callback so that awaiters
// get dispatched on the next available frame.
checkDisplayLink(CVDisplayLinkStart(displayLink.value))
}

// We alloc directly to nativeHeap because this singleton object lives for the duration of the
// process. We don't care about cleanup and therefore never free this.
private val displayLink = nativeHeap.alloc<CVDisplayLinkRefVar>()

init {
checkDisplayLink(CVDisplayLinkCreateWithActiveCGDisplays(displayLink.ptr))
checkDisplayLink(
CVDisplayLinkSetOutputCallback(
displayLink.value,
staticCFunction { _, _, _, _, _, _ ->
clock.sendFrame(0L)

// A frame was delivered. Stop the DisplayLink callback. It will get started again
// when new frame awaiters appear.
CVDisplayLinkStop(displayLink.value)
},
null,
),
)
}

override suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R {
return clock.withFrameNanos(onFrame)
}
}

private fun checkDisplayLink(code: Int) {
check(code == kCVReturnSuccess) { "Could not initialize CVDisplayLink. Error code $code." }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Copyright (C) 2023 Square, Inc.
*
* 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 app.cash.molecule

import androidx.compose.runtime.BroadcastFrameClock
import androidx.compose.runtime.MonotonicFrameClock
import kotlinx.cinterop.ObjCAction
import platform.Foundation.NSRunLoop
import platform.Foundation.NSSelectorFromString
import platform.QuartzCore.CADisplayLink

public actual object DisplayLinkClock : MonotonicFrameClock {

@Suppress("unused") // This registers a DisplayLink listener.
private val displayLink: CADisplayLink = CADisplayLink.displayLinkWithTarget(
target = this,
selector = NSSelectorFromString(this::tickClock.name),
)

private val clock = BroadcastFrameClock {
// We only want to listen to the DisplayLink run loop if we have frame awaiters.
displayLink.addToRunLoop(NSRunLoop.currentRunLoop, NSRunLoop.currentRunLoop.currentMode)
}

override suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R {
return clock.withFrameNanos(onFrame)
}

// The following function must remain public to be a valid candidate for the call to
// NSSelectorString above.
@ObjCAction public fun tickClock() {
clock.sendFrame(0L)

// Remove the DisplayLink from the run loop. It will get added again if new frame awaiters
// appear.
displayLink.removeFromRunLoop(NSRunLoop.currentRunLoop, NSRunLoop.currentRunLoop.currentMode)
}
}

0 comments on commit 7b6bb17

Please sign in to comment.