Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 패키지는 그 제품이 생길 때.

## 입력·선택·공유 달력 확장

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions android/CHANGELOG.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

**바뀐 것**
Expand Down
8 changes: 8 additions & 0 deletions android/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>` 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**
Expand Down
4 changes: 2 additions & 2 deletions android/README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
```

Expand Down
4 changes: 2 additions & 2 deletions android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = "종류") }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -83,8 +85,9 @@ public class DdsSegment<out T>(
* 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
Expand Down Expand Up @@ -115,6 +118,10 @@ public class DdsSegment<out T>(
* @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 <T> DdsSegmentedControl(
Expand All @@ -128,6 +135,7 @@ public fun <T> DdsSegmentedControl(
error: String? = null,
enabled: Boolean = true,
readOnly: Boolean = false,
slideAnimationSpec: AnimationSpec<Float> = DdsSegmentedDefaults.SlideAnimationSpec,
) {
checkSegments(segments) // a handful of segments: cheap enough on every composition
val colors = DdsTheme.colors
Expand All @@ -146,6 +154,7 @@ public fun <T> 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).
Expand Down Expand Up @@ -188,14 +197,22 @@ public fun <T> 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<Float>?,
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<List<IntRange>>(emptyList()) }
Expand All @@ -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<Float>(DdsMotion.Base, easing = DdsMotion.Standard)
launch { thumbStart.animateTo(start, slide) }
launch { thumbEnd.animateTo(end, slide) }
}
Expand Down Expand Up @@ -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<Float> = 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<Float>, reducedMotion: Boolean): AnimationSpec<Float>? =
if (reducedMotion) null else spec

internal fun checkSegments(segments: List<DdsSegment<*>>) {
require(segments.isNotEmpty()) { "DdsSegmentedControl: segments must not be empty" }
val seen = HashSet<Any?>()
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -27,4 +30,17 @@ class SegmentedTest {
fun theSameLabelOnTwoValuesIsAllowed() {
checkSegments(listOf(DdsSegment(1, "A"), DdsSegment(2, "A")))
}

@Test
fun theSlideAnAppPassesIsTheOneUsed() {
val slow = tween<Float>(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))
}
}
2 changes: 1 addition & 1 deletion android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion docs/mobile-native.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
6 changes: 5 additions & 1 deletion docs/mobile-native.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading