diff --git a/README.ko.md b/README.ko.md index 01a50579..f6eab3ab 100644 --- a/README.ko.md +++ b/README.ko.md @@ -120,7 +120,7 @@ Source-Available License 1.0을 따른다. compatibility canary는 비공개· 때만 만든다 (명세 §8): `@devslab/dds-tokens` (✅ 2026-08-13) → `dds-css` v1 인벤토리 (Core 6종 ✅ 2026-08-13, +13종 ✅ 2026-08-15) → AskLinq 적용 → `dds-icons` (✅ 2026-08-15) → `dds-solid` + `site-kit` → 안드로이드 네이티브 값·테마·모션 -(0.6.0, 2026-11-12, Maven Central — 테마·모션·부품·아이콘). 0.7.0(2026-11-13, Maven Central)에서 화면 틀과 `dds-compose-testing` 꾸러미가 생겼다(D-052). 0.8.0(2026-11-13)에서 `DdsSegmentedControl`이 생겼고, 0.8.1에서 어두운 화면의 고른 칸이 밝아졌고(`bg.raised`, D-054), 0.9.0에서 고른 면이 미끄러져 옮겨 간다(D-055). RN·Ionic 패키지는 그 제품이 생길 때. +(0.6.0, 2026-11-12, Maven Central — 테마·모션·부품·아이콘). 0.7.0(2026-11-13, Maven Central)에서 화면 틀과 `dds-compose-testing` 꾸러미가 생겼다(D-052). 0.8.0(2026-11-13)에서 `DdsSegmentedControl`이 생겼고, 0.8.1에서 어두운 화면의 고른 칸이 밝아졌고(`bg.raised`, D-054), 0.9.0에서 고른 면이 미끄러져 옮겨 가고(D-055), 0.9.1에서 앱이 그 속도를 정할 수 있다. RN·Ionic 패키지는 그 제품이 생길 때. ## 입력·선택·공유 달력 확장 diff --git a/README.md b/README.md index 9531b98c..03184428 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Priorities live in [`docs/backlog.md`](docs/backlog.md). Packages are created only when a consumer exists (spec §8): `@devslab/dds-tokens` (✅ 2026-08-13) → `dds-css` v1 inventory (Core 6 ✅ 2026-08-13, +13 ✅ 2026-08-15) → AskLinq adoption → `dds-icons` (✅ 2026-08-15) → `dds-solid` + `site-kit` → native Android values, theme and motion -(0.6.0, 2026-11-12, on Maven Central — theme, motion, components, icons). 0.7.0 (2026-11-13, on Maven Central) adds the screen frame and the `dds-compose-testing` artifact (D-052). 0.8.0 (2026-11-13) adds `DdsSegmentedControl`, 0.8.1 its lighter chosen face in dark (`bg.raised`, D-054), and 0.9.0 makes that face slide between choices (D-055). RN and Ionic packages only when such a product exists. +(0.6.0, 2026-11-12, on Maven Central — theme, motion, components, icons). 0.7.0 (2026-11-13, on Maven Central) adds the screen frame and the `dds-compose-testing` artifact (D-052). 0.8.0 (2026-11-13) adds `DdsSegmentedControl`, 0.8.1 its lighter chosen face in dark (`bg.raised`, D-054), 0.9.0 makes that face slide between choices (D-055), and 0.9.1 lets an app set the slide speed. RN and Ionic packages only when such a product exists. ## Input, choice and shared calendar additions diff --git a/android/CHANGELOG.ko.md b/android/CHANGELOG.ko.md index af50db5d..01979734 100644 --- a/android/CHANGELOG.ko.md +++ b/android/CHANGELOG.ko.md @@ -5,6 +5,14 @@ `kr.devslab:dds-android-tokens`, `kr.devslab:dds-compose`, 그리고 0.7.0부터 `kr.devslab:dds-compose-testing`은 한 버전을 쓰고, 웹 패키지와는 버전이 따로다(D-048). 릴리스는 `android-v<버전>` 태그로 낸다. +## 0.9.1 — 2026-11-13 + +**새로 생긴 것** + +- **`DdsSegmentedControl(slideAnimationSpec = …)`, `DdsSegmentedDefaults.SlideAnimationSpec`** — 고른 면이 옮겨 가는 + 속도를 앱이 정한다. 기본값은 0.9.0과 같은 200ms `Standard`이고, 바꾸고 싶은 앱만 `tween(…)` 등을 넘긴다. 기기에서 + 애니메이션을 끄면 넘긴 값과 상관없이 바로 옮겨 간다. 기존 호출은 그대로다. + ## 0.9.0 — 2026-11-13 **바뀐 것** diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 8c5099fd..53734ee0 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -5,6 +5,14 @@ `kr.devslab:dds-android-tokens`, `kr.devslab:dds-compose` and (from 0.7.0) `kr.devslab:dds-compose-testing` share one version, separate from the web packages (D-048). Releases are cut by an `android-v` tag. +## 0.9.1 — 2026-11-13 + +**New** + +- **`DdsSegmentedControl(slideAnimationSpec = …)`, `DdsSegmentedDefaults.SlideAnimationSpec`** — an app sets how fast the + chosen face travels. The default is 0.9.0's 200ms `Standard`; only an app that wants another passes a `tween(…)` or any + spec. With animations off on the device the face moves at once, whatever is passed. Existing calls are unchanged. + ## 0.9.0 — 2026-11-13 **Changed** diff --git a/android/README.ko.md b/android/README.ko.md index c8092a41..034e762d 100644 --- a/android/README.ko.md +++ b/android/README.ko.md @@ -26,8 +26,8 @@ dependencies { ```kotlin dependencies { - implementation("kr.devslab:dds-compose:0.9.0") - androidTestImplementation("kr.devslab:dds-compose-testing:0.9.0") + implementation("kr.devslab:dds-compose:0.9.1") + androidTestImplementation("kr.devslab:dds-compose-testing:0.9.1") } ``` diff --git a/android/README.md b/android/README.md index d5263d6c..bc05aca0 100644 --- a/android/README.md +++ b/android/README.md @@ -26,8 +26,8 @@ dependencies { ```kotlin dependencies { - implementation("kr.devslab:dds-compose:0.9.0") - androidTestImplementation("kr.devslab:dds-compose-testing:0.9.0") + implementation("kr.devslab:dds-compose:0.9.1") + androidTestImplementation("kr.devslab:dds-compose-testing:0.9.1") } ``` diff --git a/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/SegmentedOnDeviceTest.kt b/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/SegmentedOnDeviceTest.kt index 505444ea..e4d69727 100644 --- a/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/SegmentedOnDeviceTest.kt +++ b/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/SegmentedOnDeviceTest.kt @@ -37,6 +37,8 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween import androidx.compose.ui.unit.dp import kotlin.math.abs import kr.devslab.dds.compose.component.DdsSegment @@ -255,6 +257,33 @@ class SegmentedOnDeviceTest { assertEquals("end width", to.width, end.width, 1f) } + @Test + fun anAppCanSlowTheSlideDown() { + assumeFalse("animations are turned off on this device", isReducedMotion(InstrumentationRegistry.getInstrumentation().targetContext)) + var chosen by mutableStateOf("person") + show { + DdsSegmentedControl( + kinds, + chosen, + { chosen = it }, + contentDescription = "종류", + fullWidth = true, + slideAnimationSpec = tween(1_000, easing = LinearEasing), + ) + } + val from = segment("개인") + val to = segment("비즈니스") + rule.mainClock.autoAdvance = false + rule.onNodeWithText("비즈니스").performClick() + // Past the default 200ms, the app's 1s slide is still about a third of the way. + rule.mainClock.advanceTimeBy(350) + val midway = thumb() + assertTrue("still travelling at 350ms: $midway", midway.left > from.left + 1f && midway.left < to.left - 1f) + rule.mainClock.autoAdvance = true + rule.waitForIdle() + assertEquals("it ends on the new segment", to.left, thumb().left, 1f) + } + @Test fun noChoiceDrawsNoFace() { show { DdsSegmentedControl(kinds, "nothing", {}, contentDescription = "종류") } diff --git a/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/component/Segmented.kt b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/component/Segmented.kt index 67bc3861..c3589185 100644 --- a/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/component/Segmented.kt +++ b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/component/Segmented.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.testTag import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.tween import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableIntStateOf @@ -39,6 +40,7 @@ import androidx.compose.runtime.setValue import kotlin.math.roundToInt import kotlinx.coroutines.launch import kr.devslab.dds.compose.motion.DdsMotion +import kr.devslab.dds.compose.motion.rememberReducedMotion import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.error @@ -83,8 +85,9 @@ public class DdsSegment( * one is a `bg.raised` face with a 1dp `border.strong` line, a level-1 * shadow, `text.primary` and weight 600. * - **Motion:** the chosen face slides to the new choice (200ms, standard - * easing), growing or shrinking to its width; the label colors fade. With - * animations off on the device it moves at once. The first frame already + * easing, unless the app passes its own [slideAnimationSpec]), growing or + * shrinking to its width; the label colors fade. With animations off on the + * device it moves at once, whatever spec is passed. The first frame already * shows it on the chosen segment. * - **Height:** one size, the web's touch height. The track is 50dp (48dp * inside its line); each segment is 48dp of touch with a 44dp face and is @@ -115,6 +118,10 @@ public class DdsSegment( * @param enabled false greys out the whole control and ignores touches * @param readOnly shows the choice, can be focused and read, but cannot be * changed. Not greyed out + * @param slideAnimationSpec how the chosen face travels to a new choice: 200ms + * with the standard easing by default ([DdsSegmentedDefaults.SlideAnimationSpec]). + * An app may pass its own (a slower `tween`, say). With animations turned off on + * the device the face moves at once, whatever is passed here */ @Composable public fun DdsSegmentedControl( @@ -128,6 +135,7 @@ public fun DdsSegmentedControl( error: String? = null, enabled: Boolean = true, readOnly: Boolean = false, + slideAnimationSpec: AnimationSpec = DdsSegmentedDefaults.SlideAnimationSpec, ) { checkSegments(segments) // a handful of segments: cheap enough on every composition val colors = DdsTheme.colors @@ -146,6 +154,7 @@ public fun DdsSegmentedControl( fullWidth = fullWidth, touch = touch, chosen = segments.indexOfFirst { it.value == selected }, + slide = segmentSlide(slideAnimationSpec, reducedMotion = rememberReducedMotion()), modifier = Modifier .then(if (fullWidth) Modifier.fillMaxWidth() else Modifier) // Disabled dims the track as a whole (the web's `[data-disabled]` track). @@ -188,14 +197,22 @@ public fun DdsSegmentedControl( * otherwise each is as wide as it wants (it sits inside a horizontal scroll). * * The chosen face is one thumb behind the segments, not a face on each (D-055): - * choosing another segment slides it there, growing or shrinking to that - * segment's width on the way, over [DdsMotion.Base] with the standard easing — - * the web's indicator and [DdsTabs]'s underline move the same way. The first - * layout, and a new place for the same choice (rotation, font size), put it - * there without travel. [chosen] is -1 when no segment is chosen: no thumb. + * choosing another segment slides it there by [slide], growing or shrinking to + * that segment's width on the way — by default over [DdsMotion.Base] with the + * standard easing, as the web's indicator and [DdsTabs]'s underline move. The + * first layout, and a new place for the same choice (rotation, font size), put + * it there without travel; so does a null [slide] (animations off). [chosen] is + * -1 when no segment is chosen: no thumb. */ @Composable -private fun SegmentRow(fullWidth: Boolean, touch: Int, chosen: Int, modifier: Modifier, content: @Composable () -> Unit) { +private fun SegmentRow( + fullWidth: Boolean, + touch: Int, + chosen: Int, + slide: AnimationSpec?, + modifier: Modifier, + content: @Composable () -> Unit, +) { // Where each segment starts and ends, in px from the start edge. Written when the row is // placed; read when the choice changes, never while the row is measured. var spans by remember { mutableStateOf>(emptyList()) } @@ -207,11 +224,10 @@ private fun SegmentRow(fullWidth: Boolean, touch: Int, chosen: Int, modifier: Mo span ?: return@LaunchedEffect val start = span.first.toFloat() val end = (span.last + 1).toFloat() - if (thumbFor == chosen || thumbFor == -1) { + if (thumbFor == chosen || thumbFor == -1 || slide == null) { thumbStart.snapTo(start) thumbEnd.snapTo(end) } else { - val slide = tween(DdsMotion.Base, easing = DdsMotion.Standard) launch { thumbStart.animateTo(start, slide) } launch { thumbEnd.animateTo(end, slide) } } @@ -379,6 +395,23 @@ private fun Segment( } } +/** The default values of [DdsSegmentedControl]. */ +public object DdsSegmentedDefaults { + /** + * How the chosen face travels to a new choice: [DdsMotion.Base] (200ms) with the + * [DdsMotion.Standard] easing — the same move as the web's indicator and [DdsTabs]'s + * underline (D-055). + */ + public val SlideAnimationSpec: AnimationSpec = tween(DdsMotion.Base, easing = DdsMotion.Standard) +} + +/** + * The slide the thumb uses, or null to move at once. Animations turned off on the device win + * over any spec an app passes: the face then moves at once. + */ +internal fun segmentSlide(spec: AnimationSpec, reducedMotion: Boolean): AnimationSpec? = + if (reducedMotion) null else spec + internal fun checkSegments(segments: List>) { require(segments.isNotEmpty()) { "DdsSegmentedControl: segments must not be empty" } val seen = HashSet() diff --git a/android/dds-compose/src/test/kotlin/kr/devslab/dds/compose/component/SegmentedTest.kt b/android/dds-compose/src/test/kotlin/kr/devslab/dds/compose/component/SegmentedTest.kt index 1e26dd43..86aaa98d 100644 --- a/android/dds-compose/src/test/kotlin/kr/devslab/dds/compose/component/SegmentedTest.kt +++ b/android/dds-compose/src/test/kotlin/kr/devslab/dds/compose/component/SegmentedTest.kt @@ -1,5 +1,8 @@ package kr.devslab.dds.compose.component +import androidx.compose.animation.core.tween +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame import org.junit.Assert.assertThrows import org.junit.Test @@ -27,4 +30,17 @@ class SegmentedTest { fun theSameLabelOnTwoValuesIsAllowed() { checkSegments(listOf(DdsSegment(1, "A"), DdsSegment(2, "A"))) } + + @Test + fun theSlideAnAppPassesIsTheOneUsed() { + val slow = tween(600) + assertSame(slow, segmentSlide(slow, reducedMotion = false)) + assertSame(DdsSegmentedDefaults.SlideAnimationSpec, segmentSlide(DdsSegmentedDefaults.SlideAnimationSpec, reducedMotion = false)) + } + + @Test + fun withAnimationsOffTheFaceMovesAtOnceWhateverTheAppPassed() { + assertNull(segmentSlide(tween(600), reducedMotion = true)) + assertNull(segmentSlide(DdsSegmentedDefaults.SlideAnimationSpec, reducedMotion = true)) + } } diff --git a/android/gradle.properties b/android/gradle.properties index 7c2d7723..9b7e64d4 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -8,4 +8,4 @@ kotlin.code.style=official # Android releases are versioned apart from the web packages (docs/decisions.md D-048). # The release workflow overrides VERSION_NAME from the android-v* tag. GROUP=kr.devslab -VERSION_NAME=0.9.0 +VERSION_NAME=0.9.1 diff --git a/docs/mobile-native.ko.md b/docs/mobile-native.ko.md index bcdb32c3..2e5e8bf7 100644 --- a/docs/mobile-native.ko.md +++ b/docs/mobile-native.ko.md @@ -904,7 +904,9 @@ Compose 벡터는 `dds-icons`의 SVG에서 **생성**한다(`node scripts/genera - **고른 면은 미끄러져 옮겨 간다(0.9.0, D-055).** 칸마다 면을 그리지 않고 행 뒤에 면 하나를 둔다. 다른 칸을 고르면 200ms(`DdsMotion.Base`, `Standard` 곡선)에 걸쳐 그 칸으로 가며 폭도 따라 바뀐다 — `DdsTabs` 밑줄과 같다. 처음 그릴 때와 회전·글자 크기로 자리만 바뀔 때는 움직이지 않는다. 기기에서 애니메이션을 끄면 바로 옮겨 간다. - 웹도 같다. + 웹도 같다. **속도는 앱이 정할 수 있다(0.9.1):** 기본값은 그대로 두고, 앱이 + `slideAnimationSpec = tween(400, easing = DdsMotion.Standard)`처럼 넘기면 그 값으로 움직인다(기본값은 + `DdsSegmentedDefaults.SlideAnimationSpec`). 기기에서 애니메이션을 껐으면 넘긴 값과 상관없이 바로 옮겨 간다. - **고른 칸은 트랙보다 밝은 면이다(0.8.1, D-054).** 카드·메뉴의 그림자는 뺐지만(§10의 5번) 이 면은 웹 그대로 1단계 그림자를 쓴다. 어두운 화면에서는 그림자가 보이지 않으므로 면 자체가 트랙 (`bg.subtle` `zinc.900`)보다 한 단계 밝은 `bg.raised`(`zinc.800`)다. 0.8.0은 `bg.default` diff --git a/docs/mobile-native.md b/docs/mobile-native.md index b513fc98..d8af8779 100644 --- a/docs/mobile-native.md +++ b/docs/mobile-native.md @@ -950,7 +950,11 @@ end-aligned menu). segment. Choosing another segment moves it there over 200ms (`DdsMotion.Base`, the `Standard` easing), its width following — as `DdsTabs`'s underline moves. The first layout, and a new place for the same choice (rotation, font size), put it there without travel. With animations - off on the device it moves at once. The web does the same. + off on the device it moves at once. The web does the same. **An app can set the speed (0.9.1):** + the default stays, and an app that passes, say, `slideAnimationSpec = tween(400, easing = + DdsMotion.Standard)` gets that move instead (the default is + `DdsSegmentedDefaults.SlideAnimationSpec`). With animations off on the device the face still + moves at once, whatever is passed. - **The chosen face is lighter than the track (0.8.1, D-054).** Cards and menus dropped their shadow (§10 no. 5), but this face keeps the web's level-1 shadow. In the dark theme, where the shadow does not show, the face itself is `bg.raised` (`zinc.800`), one step lighter than the