Skip to content

Commit

Permalink
Merge pull request #2471 from DataDog/jmoskovich/RUM-6199/compose-switch
Browse files Browse the repository at this point in the history
RUM-6199: Add Semantics mapper for Switch
  • Loading branch information
jonathanmos authored Jan 7, 2025
2 parents 285ae49 + c9d5572 commit ce2ed62
Show file tree
Hide file tree
Showing 4 changed files with 430 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ internal class RootSemanticsNodeMapper(
private val colorStringFormatter: ColorStringFormatter,
private val semanticsUtils: SemanticsUtils = SemanticsUtils(),
private val semanticsNodeMapper: Map<Role, SemanticsNodeMapper> = mapOf(
// TODO RUM-6189 Add Mappers for each Semantics Role
Role.RadioButton to RadioButtonSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
Role.Tab to TabSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
Role.Button to ButtonSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
Role.Image to ImageSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
Role.Checkbox to CheckboxSemanticsNodeMapper(colorStringFormatter, semanticsUtils)
Role.Checkbox to CheckboxSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
Role.Switch to SwitchSemanticsNodeMapper(colorStringFormatter, semanticsUtils)
),
// Text doesn't have a role in semantics, so it should be a fallback mapper.
private val textSemanticsNodeMapper: TextSemanticsNodeMapper = TextSemanticsNodeMapper(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

package com.datadog.android.sessionreplay.compose.internal.mappers.semantics

import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.state.ToggleableState
import com.datadog.android.sessionreplay.TextAndInputPrivacy
import com.datadog.android.sessionreplay.compose.internal.data.SemanticsWireframe
import com.datadog.android.sessionreplay.compose.internal.data.UiContext
import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils
import com.datadog.android.sessionreplay.model.MobileSegment
import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback
import com.datadog.android.sessionreplay.utils.ColorStringFormatter
import com.datadog.android.sessionreplay.utils.GlobalBounds

internal class SwitchSemanticsNodeMapper(
colorStringFormatter: ColorStringFormatter,
semanticsUtils: SemanticsUtils = SemanticsUtils()
) : AbstractSemanticsNodeMapper(colorStringFormatter, semanticsUtils) {
override fun map(
semanticsNode: SemanticsNode,
parentContext: UiContext,
asyncJobStatusCallback: AsyncJobStatusCallback
): SemanticsWireframe {
val isSwitchOn = isSwitchOn(semanticsNode)
val globalBounds = resolveBounds(semanticsNode)

val switchWireframes = if (isSwitchMasked(parentContext)) {
listOf(
resolveMaskedWireframes(
semanticsNode = semanticsNode,
globalBounds = globalBounds,
wireframeIndex = 0
)
)
} else {
val trackWireframe = createTrackWireframe(
semanticsNode = semanticsNode,
globalBounds = globalBounds,
wireframeIndex = 0,
isSwitchOn = isSwitchOn
)

val thumbWireframe = createThumbWireframe(
semanticsNode = semanticsNode,
globalBounds = globalBounds,
wireframeIndex = 1,
isSwitchOn = isSwitchOn
)

listOfNotNull(trackWireframe, thumbWireframe)
}

return SemanticsWireframe(
uiContext = null,
wireframes = switchWireframes
)
}

private fun createTrackWireframe(
semanticsNode: SemanticsNode,
globalBounds: GlobalBounds,
wireframeIndex: Int,
isSwitchOn: Boolean
): MobileSegment.Wireframe {
val trackColor = if (isSwitchOn) {
DEFAULT_COLOR_BLACK
} else {
DEFAULT_COLOR_WHITE
}

@Suppress("MagicNumber")
return MobileSegment.Wireframe.ShapeWireframe(
resolveId(semanticsNode, wireframeIndex),
x = globalBounds.x,
y = globalBounds.y + (globalBounds.height / 4),
width = TRACK_WIDTH_DP,
height = THUMB_DIAMETER_DP.toLong() / 2,
shapeStyle = MobileSegment.ShapeStyle(
cornerRadius = CORNER_RADIUS_DP,
backgroundColor = trackColor
),
border = MobileSegment.ShapeBorder(
color = DEFAULT_COLOR_BLACK,
width = BORDER_WIDTH_DP
)
)
}

private fun createThumbWireframe(
semanticsNode: SemanticsNode,
globalBounds: GlobalBounds,
wireframeIndex: Int,
isSwitchOn: Boolean
): MobileSegment.Wireframe {
val xPosition = if (!isSwitchOn) {
globalBounds.x
} else {
globalBounds.x + globalBounds.width - THUMB_DIAMETER_DP
}

@Suppress("MagicNumber")
val yPosition = globalBounds.y + (globalBounds.height / 4) - (THUMB_DIAMETER_DP / 4)

val thumbColor = if (!isSwitchOn) {
DEFAULT_COLOR_WHITE
} else {
DEFAULT_COLOR_BLACK
}

return MobileSegment.Wireframe.ShapeWireframe(
resolveId(semanticsNode, wireframeIndex),
x = xPosition,
y = yPosition,
width = THUMB_DIAMETER_DP.toLong(),
height = THUMB_DIAMETER_DP.toLong(),
shapeStyle = MobileSegment.ShapeStyle(
cornerRadius = CORNER_RADIUS_DP,
backgroundColor = thumbColor
),
border = MobileSegment.ShapeBorder(
color = DEFAULT_COLOR_BLACK,
width = BORDER_WIDTH_DP
)
)
}

private fun isSwitchOn(semanticsNode: SemanticsNode): Boolean =
semanticsNode.config.getOrNull(SemanticsProperties.ToggleableState) == ToggleableState.On

private fun isSwitchMasked(parentContext: UiContext): Boolean =
parentContext.textAndInputPrivacy != TextAndInputPrivacy.MASK_SENSITIVE_INPUTS

private fun resolveMaskedWireframes(
semanticsNode: SemanticsNode,
globalBounds: GlobalBounds,
wireframeIndex: Int
): MobileSegment.Wireframe {
// TODO RUM-5118: Decide how to display masked, currently use empty track,
return createTrackWireframe(
semanticsNode = semanticsNode,
globalBounds = globalBounds,
wireframeIndex = wireframeIndex,
isSwitchOn = false
)
}

internal companion object {
const val TRACK_WIDTH_DP = 34L
const val CORNER_RADIUS_DP = 20
const val THUMB_DIAMETER_DP = 20
const val BORDER_WIDTH_DP = 1L
const val DEFAULT_COLOR_BLACK = "#000000"
const val DEFAULT_COLOR_WHITE = "#FFFFFF"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ class RootSemanticsNodeMapperTest {
@Mock
private lateinit var mockCheckboxSemanticsNodeMapper: CheckboxSemanticsNodeMapper

@Mock
private lateinit var mockSwitchSemanticsNodeMapper: SwitchSemanticsNodeMapper

@Mock
private lateinit var mockComposeHiddenMapper: ComposeHiddenMapper

Expand All @@ -99,7 +102,8 @@ class RootSemanticsNodeMapperTest {
Role.Tab to mockTabSemanticsNodeMapper,
Role.Button to mockButtonSemanticsNodeMapper,
Role.Image to mockImageSemanticsNodeMapper,
Role.Checkbox to mockCheckboxSemanticsNodeMapper
Role.Checkbox to mockCheckboxSemanticsNodeMapper,
Role.Switch to mockSwitchSemanticsNodeMapper
),
textSemanticsNodeMapper = mockTextSemanticsNodeMapper,
containerSemanticsNodeMapper = mockContainerSemanticsNodeMapper,
Expand Down Expand Up @@ -171,6 +175,27 @@ class RootSemanticsNodeMapperTest {
)
}

@Test
fun `M use SwitchSemanticsNodeMapper W createComposeWireframes { role is Switch }`() {
// Given
val mockSemanticsNode = mockSemanticsNode(Role.Switch)

// When
testedRootSemanticsNodeMapper.createComposeWireframes(
mockSemanticsNode,
fakeMappingContext.systemInformation.screenDensity,
fakeMappingContext,
mockAsyncJobStatusCallback
)

// Then
verify(mockSwitchSemanticsNodeMapper).map(
eq(mockSemanticsNode),
any(),
eq(mockAsyncJobStatusCallback)
)
}

@Test
fun `M use TabSemanticsNodeMapper W createComposeWireframes { role is Tab }`() {
// Given
Expand Down
Loading

0 comments on commit ce2ed62

Please sign in to comment.