Skip to content

Commit ce2ed62

Browse files
authored
Merge pull request #2471 from DataDog/jmoskovich/RUM-6199/compose-switch
RUM-6199: Add Semantics mapper for Switch
2 parents 285ae49 + c9d5572 commit ce2ed62

File tree

4 files changed

+430
-3
lines changed

4 files changed

+430
-3
lines changed

features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapper.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ internal class RootSemanticsNodeMapper(
2727
private val colorStringFormatter: ColorStringFormatter,
2828
private val semanticsUtils: SemanticsUtils = SemanticsUtils(),
2929
private val semanticsNodeMapper: Map<Role, SemanticsNodeMapper> = mapOf(
30-
// TODO RUM-6189 Add Mappers for each Semantics Role
3130
Role.RadioButton to RadioButtonSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
3231
Role.Tab to TabSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
3332
Role.Button to ButtonSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
3433
Role.Image to ImageSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
35-
Role.Checkbox to CheckboxSemanticsNodeMapper(colorStringFormatter, semanticsUtils)
34+
Role.Checkbox to CheckboxSemanticsNodeMapper(colorStringFormatter, semanticsUtils),
35+
Role.Switch to SwitchSemanticsNodeMapper(colorStringFormatter, semanticsUtils)
3636
),
3737
// Text doesn't have a role in semantics, so it should be a fallback mapper.
3838
private val textSemanticsNodeMapper: TextSemanticsNodeMapper = TextSemanticsNodeMapper(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.sessionreplay.compose.internal.mappers.semantics
8+
9+
import androidx.compose.ui.semantics.SemanticsNode
10+
import androidx.compose.ui.semantics.SemanticsProperties
11+
import androidx.compose.ui.semantics.getOrNull
12+
import androidx.compose.ui.state.ToggleableState
13+
import com.datadog.android.sessionreplay.TextAndInputPrivacy
14+
import com.datadog.android.sessionreplay.compose.internal.data.SemanticsWireframe
15+
import com.datadog.android.sessionreplay.compose.internal.data.UiContext
16+
import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils
17+
import com.datadog.android.sessionreplay.model.MobileSegment
18+
import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback
19+
import com.datadog.android.sessionreplay.utils.ColorStringFormatter
20+
import com.datadog.android.sessionreplay.utils.GlobalBounds
21+
22+
internal class SwitchSemanticsNodeMapper(
23+
colorStringFormatter: ColorStringFormatter,
24+
semanticsUtils: SemanticsUtils = SemanticsUtils()
25+
) : AbstractSemanticsNodeMapper(colorStringFormatter, semanticsUtils) {
26+
override fun map(
27+
semanticsNode: SemanticsNode,
28+
parentContext: UiContext,
29+
asyncJobStatusCallback: AsyncJobStatusCallback
30+
): SemanticsWireframe {
31+
val isSwitchOn = isSwitchOn(semanticsNode)
32+
val globalBounds = resolveBounds(semanticsNode)
33+
34+
val switchWireframes = if (isSwitchMasked(parentContext)) {
35+
listOf(
36+
resolveMaskedWireframes(
37+
semanticsNode = semanticsNode,
38+
globalBounds = globalBounds,
39+
wireframeIndex = 0
40+
)
41+
)
42+
} else {
43+
val trackWireframe = createTrackWireframe(
44+
semanticsNode = semanticsNode,
45+
globalBounds = globalBounds,
46+
wireframeIndex = 0,
47+
isSwitchOn = isSwitchOn
48+
)
49+
50+
val thumbWireframe = createThumbWireframe(
51+
semanticsNode = semanticsNode,
52+
globalBounds = globalBounds,
53+
wireframeIndex = 1,
54+
isSwitchOn = isSwitchOn
55+
)
56+
57+
listOfNotNull(trackWireframe, thumbWireframe)
58+
}
59+
60+
return SemanticsWireframe(
61+
uiContext = null,
62+
wireframes = switchWireframes
63+
)
64+
}
65+
66+
private fun createTrackWireframe(
67+
semanticsNode: SemanticsNode,
68+
globalBounds: GlobalBounds,
69+
wireframeIndex: Int,
70+
isSwitchOn: Boolean
71+
): MobileSegment.Wireframe {
72+
val trackColor = if (isSwitchOn) {
73+
DEFAULT_COLOR_BLACK
74+
} else {
75+
DEFAULT_COLOR_WHITE
76+
}
77+
78+
@Suppress("MagicNumber")
79+
return MobileSegment.Wireframe.ShapeWireframe(
80+
resolveId(semanticsNode, wireframeIndex),
81+
x = globalBounds.x,
82+
y = globalBounds.y + (globalBounds.height / 4),
83+
width = TRACK_WIDTH_DP,
84+
height = THUMB_DIAMETER_DP.toLong() / 2,
85+
shapeStyle = MobileSegment.ShapeStyle(
86+
cornerRadius = CORNER_RADIUS_DP,
87+
backgroundColor = trackColor
88+
),
89+
border = MobileSegment.ShapeBorder(
90+
color = DEFAULT_COLOR_BLACK,
91+
width = BORDER_WIDTH_DP
92+
)
93+
)
94+
}
95+
96+
private fun createThumbWireframe(
97+
semanticsNode: SemanticsNode,
98+
globalBounds: GlobalBounds,
99+
wireframeIndex: Int,
100+
isSwitchOn: Boolean
101+
): MobileSegment.Wireframe {
102+
val xPosition = if (!isSwitchOn) {
103+
globalBounds.x
104+
} else {
105+
globalBounds.x + globalBounds.width - THUMB_DIAMETER_DP
106+
}
107+
108+
@Suppress("MagicNumber")
109+
val yPosition = globalBounds.y + (globalBounds.height / 4) - (THUMB_DIAMETER_DP / 4)
110+
111+
val thumbColor = if (!isSwitchOn) {
112+
DEFAULT_COLOR_WHITE
113+
} else {
114+
DEFAULT_COLOR_BLACK
115+
}
116+
117+
return MobileSegment.Wireframe.ShapeWireframe(
118+
resolveId(semanticsNode, wireframeIndex),
119+
x = xPosition,
120+
y = yPosition,
121+
width = THUMB_DIAMETER_DP.toLong(),
122+
height = THUMB_DIAMETER_DP.toLong(),
123+
shapeStyle = MobileSegment.ShapeStyle(
124+
cornerRadius = CORNER_RADIUS_DP,
125+
backgroundColor = thumbColor
126+
),
127+
border = MobileSegment.ShapeBorder(
128+
color = DEFAULT_COLOR_BLACK,
129+
width = BORDER_WIDTH_DP
130+
)
131+
)
132+
}
133+
134+
private fun isSwitchOn(semanticsNode: SemanticsNode): Boolean =
135+
semanticsNode.config.getOrNull(SemanticsProperties.ToggleableState) == ToggleableState.On
136+
137+
private fun isSwitchMasked(parentContext: UiContext): Boolean =
138+
parentContext.textAndInputPrivacy != TextAndInputPrivacy.MASK_SENSITIVE_INPUTS
139+
140+
private fun resolveMaskedWireframes(
141+
semanticsNode: SemanticsNode,
142+
globalBounds: GlobalBounds,
143+
wireframeIndex: Int
144+
): MobileSegment.Wireframe {
145+
// TODO RUM-5118: Decide how to display masked, currently use empty track,
146+
return createTrackWireframe(
147+
semanticsNode = semanticsNode,
148+
globalBounds = globalBounds,
149+
wireframeIndex = wireframeIndex,
150+
isSwitchOn = false
151+
)
152+
}
153+
154+
internal companion object {
155+
const val TRACK_WIDTH_DP = 34L
156+
const val CORNER_RADIUS_DP = 20
157+
const val THUMB_DIAMETER_DP = 20
158+
const val BORDER_WIDTH_DP = 1L
159+
const val DEFAULT_COLOR_BLACK = "#000000"
160+
const val DEFAULT_COLOR_WHITE = "#FFFFFF"
161+
}
162+
}

features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/RootSemanticsNodeMapperTest.kt

+26-1
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ class RootSemanticsNodeMapperTest {
7575
@Mock
7676
private lateinit var mockCheckboxSemanticsNodeMapper: CheckboxSemanticsNodeMapper
7777

78+
@Mock
79+
private lateinit var mockSwitchSemanticsNodeMapper: SwitchSemanticsNodeMapper
80+
7881
@Mock
7982
private lateinit var mockComposeHiddenMapper: ComposeHiddenMapper
8083

@@ -99,7 +102,8 @@ class RootSemanticsNodeMapperTest {
99102
Role.Tab to mockTabSemanticsNodeMapper,
100103
Role.Button to mockButtonSemanticsNodeMapper,
101104
Role.Image to mockImageSemanticsNodeMapper,
102-
Role.Checkbox to mockCheckboxSemanticsNodeMapper
105+
Role.Checkbox to mockCheckboxSemanticsNodeMapper,
106+
Role.Switch to mockSwitchSemanticsNodeMapper
103107
),
104108
textSemanticsNodeMapper = mockTextSemanticsNodeMapper,
105109
containerSemanticsNodeMapper = mockContainerSemanticsNodeMapper,
@@ -171,6 +175,27 @@ class RootSemanticsNodeMapperTest {
171175
)
172176
}
173177

178+
@Test
179+
fun `M use SwitchSemanticsNodeMapper W createComposeWireframes { role is Switch }`() {
180+
// Given
181+
val mockSemanticsNode = mockSemanticsNode(Role.Switch)
182+
183+
// When
184+
testedRootSemanticsNodeMapper.createComposeWireframes(
185+
mockSemanticsNode,
186+
fakeMappingContext.systemInformation.screenDensity,
187+
fakeMappingContext,
188+
mockAsyncJobStatusCallback
189+
)
190+
191+
// Then
192+
verify(mockSwitchSemanticsNodeMapper).map(
193+
eq(mockSemanticsNode),
194+
any(),
195+
eq(mockAsyncJobStatusCallback)
196+
)
197+
}
198+
174199
@Test
175200
fun `M use TabSemanticsNodeMapper W createComposeWireframes { role is Tab }`() {
176201
// Given

0 commit comments

Comments
 (0)