diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d6fb4ca --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to the JetCo library will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0-beta.9] - 2025-12-13 + +### 🎉 What's New +Version bumped to 1.0.0-beta.9! Thank you so much for your support. + +### ✨ Added +- **AnimatedSearchBar Component** - New search component with smooth animations for both KMP and Android + +--- + +**Note**: Starting from this version, all changes will be documented in this CHANGELOG.md file. Thank you for using JetCo! 🚀 + +## How to Read This Changelog + +- **Added** for new features +- **Changed** for changes in existing functionality +- **Deprecated** for soon-to-be removed features +- **Removed** for now removed features +- **Fixed** for any bug fixes +- **Security** for security-related improvements +- **Improved** for performance or code quality enhancements + +--- + +*For more details about releases, visit our [GitHub Releases](https://github.com/developerchunk/JetCo/releases) page.* \ No newline at end of file diff --git a/JetCoKMP/composeApp/build.gradle.kts b/JetCoKMP/composeApp/build.gradle.kts index c29a5a8..0623184 100644 --- a/JetCoKMP/composeApp/build.gradle.kts +++ b/JetCoKMP/composeApp/build.gradle.kts @@ -62,9 +62,8 @@ kotlin { implementation(compose.components.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) - implementation("org.jetbrains.compose.material:material-icons-extended:1.7.3") -// implementation(project(":jetco")) - implementation(libs.ui) + implementation(libs.material.icons.extended) + implementation(project(":jetco")) } commonTest.dependencies { implementation(libs.kotlin.test) @@ -104,6 +103,7 @@ android { } dependencies { + implementation(project(":jetco")) debugImplementation(compose.uiTooling) } diff --git a/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/App.kt b/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/App.kt index 59fe697..02c1f8c 100644 --- a/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/App.kt +++ b/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/App.kt @@ -3,6 +3,7 @@ package com.developerstring.jetco_kmp import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.* import androidx.compose.runtime.Composable +import com.developerstring.jetco_kmp.preview.AnimatedSearchBarPreview import com.developerstring.jetco_kmp.preview.CurvedCardPreview import com.developerstring.jetco_kmp.preview.SwitchButtonKMP import org.jetbrains.compose.ui.tooling.preview.Preview @@ -17,8 +18,8 @@ fun App() { // StepperPreview() // SwitchButtonKMP() // TicketCardSimple() - CurvedCardPreview() // TicketCardCustom() + AnimatedSearchBarPreview() } } diff --git a/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/AnimatedSearchBarPreview.kt b/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/AnimatedSearchBarPreview.kt new file mode 100644 index 0000000..0268efc --- /dev/null +++ b/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/AnimatedSearchBarPreview.kt @@ -0,0 +1,116 @@ +package com.developerstring.jetco_kmp.preview + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.components.search.animated_searchbar.AnimatedSearchBar +import com.developerstring.jetco_kmp.components.search.animated_searchbar.rememberAnimatedSearchBarController +import kotlinx.coroutines.launch + +@Composable +fun AnimatedSearchBarPreview() { + var query by remember { mutableStateOf("") } + val controller = rememberAnimatedSearchBarController() + var results by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(false) } + var scope = rememberCoroutineScope() + + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center + ) { + AnimatedSearchBar( + value = query, + onValueChange = { query = it }, + controller = controller, + isLoading = isLoading, + onSearch = { q -> + isLoading = true + scope.launch { + // tiny delay to simulate request + kotlinx.coroutines.delay(1200) + // produce fake results + results = if (q.isBlank()) emptyList() + else List(6) { index -> "${q.trim()} result ${index + 1}" } + isLoading = false + } + }, + onExpand = { /* optional: handle expand */ }, + onCollapse = { /* optional: handle collapse */ } + + ) + + Spacer(modifier = Modifier.height(16.dp)) + + //Simulate fake results for preview + if (results.isNotEmpty()) { + Text( + "Results", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(4.dp) + ) + + HorizontalDivider() + + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(results) { item -> + Row( + modifier = Modifier.fillMaxWidth().clickable { + // When user clicks a result: + // 1) set the query text + // 2) collapse the search bar via controller + query = item + controller.collapse() + // optional: clear results if you want them hidden + results = emptyList() + }.padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(item, modifier = Modifier.weight(1f)) + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null + ) + } + HorizontalDivider() + } + } + } else { + // optional empty-state hint + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + "Type and press search to simulate results", + style = MaterialTheme.typography.bodySmall, + color = Color.Gray + ) + } + } + } + } +} diff --git a/JetCoKMP/jetco/build.gradle.kts b/JetCoKMP/jetco/build.gradle.kts index e5e4f2f..679956e 100644 --- a/JetCoKMP/jetco/build.gradle.kts +++ b/JetCoKMP/jetco/build.gradle.kts @@ -134,7 +134,7 @@ mavenPublishing { signAllPublications() - coordinates("com.developerstring.jetco-kmp", "ui", "1.0.0-beta.8") + coordinates("com.developerstring.jetco-kmp", "ui", "1.0.0-beta.9") pom { name = "JetCo" diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBar.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBar.kt new file mode 100644 index 0000000..c09f358 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBar.kt @@ -0,0 +1,315 @@ +package com.developerstring.jetco_kmp.components.search.animated_searchbar + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch + +/** + * A modern animated search bar component with smooth expansion, + * bounce effects, loading indicators, and full user control through a configuration + * system and controller API. + * + * This composable provides a minimal collapsed circular search button that expands + * into a full search bar with text input, clear action, and optional loading state. + * The expansion uses customizable spring animations, scale pop effects, and animated + * width transitions. Developers can trigger collapse externally using the + * [AnimatedSearchBarController]. + * + * ## Features: + * - Smooth expand/collapse animations using spring physics + * - Icon bounce & rotation animation on expansion + * - Configurable width, height, colors, radii, padding, and corner styling + * - Optional loading indicator that replaces the search icon when searching + * - Clear button with optional collapse behavior + * - IME "Search" action callback support + * - External collapse control through [AnimatedSearchBarController] + * - Fully customizable through [AnimatedSearchBarConfig] and + * [AnimatedSearchBarAnimationConfig] + * + * ## Example Usage: + * ```kotlin + * val query by remember { mutableStateOf("") } + * val controller = rememberAnimatedSearchBarController() + * + * AnimatedSearchBar( + * value = query, + * onValueChange = { query = it }, + * controller = controller, + * onSearch = { text -> + * // trigger real search + * }, + * config = AnimatedSearchBarConfig( + * collapsedWidth = 56.dp, + * expandedWidth = 300.dp, + * height = 48.dp, + * barBackgroundColor = Color.White, + * barCornerRadius = 32.dp + * ), + * animationConfig = AnimatedSearchBarAnimationConfig( + * widthSpringStiffness = Spring.StiffnessLow, + * widthSpringDamping = Spring.DampingRatioMediumBouncy + * ) + * ) + * ``` + * ## Collapsing search bar programmatically + * You can collapse the bar manually using the controller: + * + * ```kotlin + * controller.collapse() + * ``` + * This is useful when the search results list captures a click event. + * + * @param value Current text inside the search input. + * @param onValueChange Callback invoked when the text input changes. + * @param modifier Modifier applied to the entire search bar layout. + * @param config Appearance configuration. See [AnimatedSearchBarConfig]. + * @param animationConfig Animation configuration. See [AnimatedSearchBarAnimationConfig]. + * @param isLoading Displays a circular loading indicator instead of the search icon when true. + * @param onSearch Callback triggered when the user presses IME Search. + * @param controller Controls external behavior such as collapsing the search bar. + * @param onExpand Callback invoked when the bar expands. + * @param onCollapse Callback invoked when the bar collapses. + * + * @see AnimatedSearchBarConfig for appearance configuration options + * @see AnimatedSearchBarAnimationConfig for animation configuration options + * @see [AnimatedSearchBarController] for external collapse control + * + */ + +@Composable +fun AnimatedSearchBar( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + config: AnimatedSearchBarConfig = AnimatedSearchBarConfig(), + animationConfig: AnimatedSearchBarAnimationConfig = AnimatedSearchBarAnimationConfig(), + isLoading: Boolean = false, + onSearch: (String) -> Unit = {}, + onExpand: () -> Unit = {}, + onCollapse: () -> Unit = {}, + controller: AnimatedSearchBarController = rememberAnimatedSearchBarController() +) { + var isExpanded by remember { mutableStateOf(false) } + + val iconScale = remember { Animatable(1f) } + + val scope = rememberCoroutineScope() + + fun triggerSearchIconBounceAnimation() { + scope.launch { + iconScale.animateTo( + 1.5f, animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ) + iconScale.animateTo( + 1f, animationSpec = spring( + dampingRatio = Spring.DampingRatioHighBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ) + } + } + + + val iconRotation by animateFloatAsState( + targetValue = if (isExpanded) 360f else 0f, animationSpec = tween( + durationMillis = 500, easing = LinearOutSlowInEasing + ) + ) + + val searchBarWidth by animateDpAsState( + targetValue = if (isExpanded) config.expandedWidth else config.collapsedWidth, + animationSpec = spring( + dampingRatio = animationConfig.widthSpringDamping, + stiffness = animationConfig.widthSpringStiffness + ) + ) + + val searchBarAlpha by animateFloatAsState( + targetValue = if (isExpanded) 1f else 0f, animationSpec = tween(durationMillis = 500) + ) + + val searchBarScale by animateFloatAsState( + targetValue = if (isExpanded) 1f else 0.8f, animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium + ) + ) + + val focusManager = LocalFocusManager.current + + LaunchedEffect(Unit) { + controller.collapseRequest = { + isExpanded = false + onCollapse() + triggerSearchIconBounceAnimation() + focusManager.clearFocus() + } + } + + Box( + modifier = modifier.width(searchBarWidth).height(config.height) + ) { + Box( + modifier = Modifier.fillMaxWidth().fillMaxHeight().scale(searchBarScale) + .alpha(searchBarAlpha).background( + color = config.searchBarBackgroundColor, + shape = RoundedCornerShape(config.searchBarCornerRadius) + ).border( + width = config.searchBarBorderWidth, + color = config.searchBarBorderColor, + shape = RoundedCornerShape(config.searchBarCornerRadius) + ) + ) + + + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier.size(config.height).padding(6.dp).scale(iconScale.value) + .rotate(iconRotation).background( + color = config.iconBackgroundColor, shape = CircleShape + ).clip(RoundedCornerShape(64.dp)).clickable { + if (!isExpanded) { + isExpanded = true + onExpand() + } + triggerSearchIconBounceAnimation() + }, contentAlignment = Alignment.Center + ) { + + val iconSize = config.height / 2 + + if (isExpanded && isLoading) { + CircularProgressIndicator( + strokeWidth = 2.dp, + color = config.iconTint, + modifier = Modifier.size(iconSize) + ) + } else { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = config.iconTint, + modifier = Modifier.size(iconSize) + ) + } + } + AnimatedVisibility( + visible = isExpanded, enter = fadeIn(tween(200)), exit = fadeOut(tween(150)) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f).padding(horizontal = 16.dp) + ) { + BasicTextField( + value = value, + onValueChange = { onValueChange(it) }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + onSearch(value) + focusManager.clearFocus() + }), + textStyle = TextStyle( + color = Color.Black, fontSize = 16.sp, lineHeight = 18.sp + ), + cursorBrush = SolidColor(Color.DarkGray), + decorationBox = { innerTextField -> + if (value.isEmpty()) { + Box( + modifier = Modifier, contentAlignment = Alignment.CenterStart + ) { + Text( + config.placeholderTextString, + color = config.placeholderTextColor, + fontSize = 16.sp, + modifier = Modifier.padding(start = 2.dp) + ) + } + } + innerTextField() + }) + + IconButton( + onClick = { + if (value.isNotEmpty()) { + onValueChange("") + focusManager.clearFocus() + } else { + onValueChange("") + isExpanded = false + onCollapse() + triggerSearchIconBounceAnimation() + } + }, modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Clear Search", + tint = config.clearIconTint, + modifier = Modifier.size(24.dp) + ) + } + } + } + } + } +} + diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarAnimationConfig.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarAnimationConfig.kt new file mode 100644 index 0000000..ca88bb2 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarAnimationConfig.kt @@ -0,0 +1,46 @@ +package com.developerstring.jetco_kmp.components.search.animated_searchbar + +import androidx.compose.animation.core.Spring + +/** + * Animation configuration class for [AnimatedSearchBar]. + * + * Controls spring physics, animation timings, and overshoot values for both the + * width expansion and the scale "pop" effect. + * + * @param rotationDuration Duration (in milliseconds) of the search-icon rotation + * that occurs during expansion. + * Default is **500ms**. + * + * @param bounceStiffness Spring stiffness applied to the icon bounce animation. + * Lower stiffness = softer bounce, higher stiffness = snappier. + * Default is **Spring.StiffnessMediumLow**. + * + * @param bounceDamping Spring damping ratio for bounce animation. + * Higher damping reduces oscillation. + * Default is **Spring.DampingRatioMediumBouncy**. + * + * @param widthSpringStiffness Spring stiffness for width expansion/collapse animation. + * Controls how fast the bar grows/shrinks. + * Default is **Spring.StiffnessLow**. + * + * @param widthSpringDamping Damping ratio applied during width animation. + * Controls how much overshoot or bounce occurs. + * Default is **Spring.DampingRatioLowBouncy**. + * + * @param fadeDuration Duration (in milliseconds) for fade-in / fade-out transitions + * used when showing or hiding content inside the expanded bar. + * Default is **200ms**. + * + * @see AnimatedSearchBarConfig for appearance configuration options + */ +data class AnimatedSearchBarAnimationConfig( + val rotationDuration: Int = 500, + val bounceStiffness: Float = Spring.StiffnessMediumLow, + val bounceDamping: Float = Spring.DampingRatioMediumBouncy, + + val widthSpringStiffness: Float = Spring.StiffnessLow, + val widthSpringDamping: Float = Spring.DampingRatioLowBouncy, + + val fadeDuration: Int = 200, +) \ No newline at end of file diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarConfig.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarConfig.kt new file mode 100644 index 0000000..ddd411d --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarConfig.kt @@ -0,0 +1,74 @@ +package com.developerstring.jetco_kmp.components.search.animated_searchbar + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Configuration class for the visual appearance and behaviour of [AnimatedSearchBar]. + * + * Use this to tune sizes, colors, radii, spacing and other visual defaults. + * + * @param collapsedWidth Width of the bar when collapsed into a circular icon. + * Default is 64.dp. + * + * @param expandedWidth Target width when the search bar is fully expanded. + * Default is 320.dp. + * Note: The parent layout may still constrain this width. + * + * @param height Total height of the search bar, including icon and text field. + * Default is 48.dp. + * + * @param searchBarBackgroundColor Background color of the expanded search bar container. + * + * @param searchBarBorderWidth Width of the border around the expanded bar. + * Default is 2.dp. + * + * @param searchBarBorderColor Border color for the expanded bar. + * Only visible when `barBorderWidth` > 0.dp. + * + * @param searchBarCornerRadius Corner radius of the expanded search bar container. + * Default is 35.dp for a rounded-pill shape. + * + * @param iconBackgroundColor Background color of the circular icon container in collapsed mode. + * + * @param iconTint Tint color for the search icon inside the collapsed button. + * + * @param clearIconTint Tint color for the clear (✕) icon shown when expanded. + * + * @param placeholderTextColor Color used for placeholder text in the input field. + * + * @param placeholderTextString Text displayed when the input field is empty. Default is "Search". + * + * Example: + * ```kotlin + * AnimatedSearchBarConfig( + * collapsedWidth = 56.dp, + * expandedWidth = 320.dp, + * height = 48.dp, + * barBackgroundColor = Color.White, + * barCornerRadius = 28.dp + * ) + * ``` + * + * @see AnimatedSearchBar for usage + * @see AnimatedSearchBarAnimationConfig for animation configuration options + */ + +data class AnimatedSearchBarConfig( + val height: Dp = 48.dp, + val expandedWidth: Dp = 320.dp, + val collapsedWidth: Dp = 64.dp, + + val searchBarBackgroundColor: Color = Color.White, + val searchBarCornerRadius: Dp = 35.dp, + val searchBarBorderColor: Color = Color(0xFFE0E0E0), + val searchBarBorderWidth: Dp = 2.dp, + + val iconBackgroundColor: Color = Color(0xFF558B2F), + val iconTint: Color = Color.White, + val clearIconTint: Color = Color.Black, + val placeholderTextColor: Color = Color.Black, + val placeholderTextString: String = "Search" +) + diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarController.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarController.kt new file mode 100644 index 0000000..3644bbc --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarController.kt @@ -0,0 +1,36 @@ +package com.developerstring.jetco_kmp.components.search.animated_searchbar + +import androidx.compose.runtime.Stable + +/** + * Controller used to interact with [AnimatedSearchBar] from outside the component. + * + * A controller enables external UI elements (such as search result lists, navigation + * actions, or parent screens) to programmatically collapse the search bar without + * requiring direct state lifting. + * + * ## Usage Example + * ```kotlin + * val controller = rememberAnimatedSearchBarController() + * + * AnimatedSearchBar( + * value = query, + * onValueChange = { query = it }, + * controller = controller + * ) + * + * // Collapse from outside (e.g., when a result is clicked) + * controller.collapse() + * ``` + * Internally, [AnimatedSearchBar] registers a collapse request callback so the controller + * always triggers the same close animation and logic as the built-in clear button. + **/ + +@Stable +class AnimatedSearchBarController { + internal var collapseRequest: (() -> Unit)? = null + + fun collapse() { + collapseRequest?.invoke() + } +} \ No newline at end of file diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/RememberAnimatedSearchBarController.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/RememberAnimatedSearchBarController.kt new file mode 100644 index 0000000..90cb255 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/RememberAnimatedSearchBarController.kt @@ -0,0 +1,25 @@ +package com.developerstring.jetco_kmp.components.search.animated_searchbar + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +/** + * Creates and remembers an instance of [AnimatedSearchBarController] for use with + * [AnimatedSearchBar]. + * + * The controller allows the parent UI to trigger collapse behavior externally, + * enabling scenarios where: + * - the user selects a search result, + * - a navigation event occurs, + * - or the screen must reset the search bar programmatically. + * + * ## Usage Example + * ```kotlin + * val controller = rememberAnimatedSearchBarController() + * ``` + **/ + +@Composable +fun rememberAnimatedSearchBarController(): AnimatedSearchBarController { + return remember { AnimatedSearchBarController() } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/stepper/CompactHorizontalStepper.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/stepper/CompactHorizontalStepper.kt index caeab9b..5dbd8e6 100644 --- a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/stepper/CompactHorizontalStepper.kt +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/stepper/CompactHorizontalStepper.kt @@ -24,11 +24,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.unit.dp import com.developerstring.jetco_kmp.components.stepper.model.StepperActionIcons import com.developerstring.jetco_kmp.components.stepper.model.StepperConfig import com.developerstring.jetco_kmp.components.stepper.model.StepperNode +import com.developerstring.jetco_kmp.components.stepper.model.StepperStatus /** * A compact horizontal stepper component for Jetpack Compose. @@ -91,8 +93,7 @@ fun CompactHorizontalStepper( // Trigger animation on composition animationTriggered = true } - - + Row( modifier = modifier .fillMaxWidth() @@ -100,10 +101,12 @@ fun CompactHorizontalStepper( verticalAlignment = Alignment.CenterVertically ) { steps.forEachIndexed { index, step -> - val isActive = index == currentStep - 1 - val isCompleted = index < currentStep-1 + val isActive = index == currentStep + val isCompleted = index < currentStep + val isError = step.status == StepperStatus.ERROR val nodeColor = when { + isError -> style.node.errorColor isCompleted -> style.node.completedColor isActive -> style.node.activeColor else -> style.node.inactiveColor @@ -117,34 +120,45 @@ fun CompactHorizontalStepper( colors = CardDefaults.cardColors(containerColor = nodeColor), onClick = { onStepClick?.invoke(index) } ) { + val prefix = "${index}_step_" Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - if (isCompleted || index == currentStep-1) { - Icon( - imageVector = stepperActionIcons.completed, - contentDescription = "Completed", - tint = style.node.actionIconColor, - modifier = Modifier.size(12.dp) + val (iconVector, iconTint, iconDescription) = when { + isError -> Triple( + stepperActionIcons.error, + style.node.actionIconColor, + prefix + "error" + ) + isCompleted || index == currentStep - 1 -> Triple( + stepperActionIcons.completed, + style.node.actionIconColor, + prefix + "completed" + ) + isActive && step.icon != null -> Triple( + step.icon, + style.node.actionIconColor, + prefix + "active" + ) + step.icon != null -> Triple( + step.icon, + style.node.idleIconColor, + prefix + "idle" + ) + else -> Triple( + stepperActionIcons.active, + style.node.idleIconColor, + prefix + "idle" ) - } else { - if (step.icon!= null) { - Icon( - imageVector = step.icon, - contentDescription = "Completed", - tint = style.node.idleIconColor, - modifier = Modifier.size(12.dp) - ) - } else { - Icon( - imageVector = stepperActionIcons.active, - contentDescription = "Completed", - tint = style.node.idleIconColor, - modifier = Modifier.size(12.dp) - ) - } } + + Icon( + imageVector = iconVector, + contentDescription = iconDescription, + tint = iconTint, + modifier = Modifier.size(12.dp) + ) } } diff --git a/README.md b/README.md index 6de1f54..fc4fd4c 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,9 @@ graph TB
Creative card layouts - + +
🔍 Animated Search Bar +
Customized Animated Search Bar @@ -373,51 +375,6 @@ kotlin {
-## 📊 **UI Components Gallery** - -

🎨 Rich, customizable components for every need

- -
- -
- -### 📈 **Charts & Data Visualization** - -| Component | Description | Platforms | Status | -|:----------|:------------|:---------:|:------:| -| **🥧 Pie Chart** | Interactive pie charts with customizable slices and animations | ![All Platforms](https://img.shields.io/badge/All-Platforms-success) | ✅ | -| **📊 Column Bar Chart** | Beautiful vertical bar charts with smooth transitions | ![All Platforms](https://img.shields.io/badge/All-Platforms-success) | ✅ | -| **🔥 Extended Bar Chart** | Advanced bar charts with multiple datasets and styling | ![All Platforms](https://img.shields.io/badge/All-Platforms-success) | ✅ | -| **🤓 Group Bar Chart** | Side-by-side comparison charts for multi-series data | ![All Platforms](https://img.shields.io/badge/All-Platforms-success) | ✅ | - -### 🎴 **Cards & Layout** - -| Component | Description | Platforms | Status | -|:----------|:------------|:---------:|:------:| -| **🎟️ Ticket Card** | Cinema-style cards with cutout design and dashed dividers | ![All Platforms](https://img.shields.io/badge/All-Platforms-success) | ✅ | - -### 🔄 **Navigation & Progress** - -| Component | Description | Platforms | Status | -|:----------|:------------|:---------:|:------:| -| **⬇️ Vertical Stepper** | Timeline-style stepper with images and descriptions | ![All Platforms](https://img.shields.io/badge/All-Platforms-success) | ✅ | -| **➡️ Horizontal Stepper** | Clean horizontal progress indicator | ![All Platforms](https://img.shields.io/badge/All-Platforms-success) | ✅ | -| **⚡ Compact Stepper** | Minimal icon-only stepper for mobile interfaces | ![All Platforms](https://img.shields.io/badge/All-Platforms-success) | ✅ | - -### 🎛️ **Interactive Controls** - -| Component | Description | Platforms | Status | -|:----------|:------------|:---------:|:------:| -| **🔘 Switch Button** | Animated toggle switch with custom styling | ![All Platforms](https://img.shields.io/badge/All-Platforms-success) | ✅ | - -
- -> 🚧 **Coming Soon:** Date Pickers, Floating Action Buttons, Bottom Sheets, and more! - ---- - -
- ## 🚀 **Getting Started**

⚡ From zero to awesome in 5 minutes!

@@ -438,6 +395,7 @@ import com.developerstring.jetco_kmp.cards.ticket.TicketCard import com.developerstring.jetco_kmp.cards.curved.CurvedCard import com.developerstring.jetco_kmp.components.stepper.VerticalStepper import com.developerstring.jetco_kmp.components.button.SwitchButton +import com.developerstring.jetco_kmp.components.search.animated_searchbar.AnimatedSearchBar // 🤖 For Android-only projects import com.developerstring.jetco.ui.charts.piechart.PieChart @@ -445,6 +403,7 @@ import com.developerstring.jetco.ui.cards.ticket.TicketCard import com.developerstring.jetco.ui.cards.curved.CurvedCard import com.developerstring.jetco.ui.components.stepper.VerticalStepper import com.developerstring.jetco.ui.components.button.SwitchButton +import com.developerstring.jetco.ui.components.search.animated_searchbar.AnimatedSearchBar ``` > 💡 **Note**: All components have **identical functionality and API** across both Android and KMP versions. Only the import path and Gradle dependency differ! @@ -602,6 +561,41 @@ fun SwitchExample() { +
+Animated Search Bar + +```kotlin +@Composable +fun AnimatedSearchBarPreview() { + val query by remember { mutableStateOf("") } + val controller = rememberAnimatedSearchBarController() + var isLoading by remember { mutableStateOf(false) } //to show loading animation when result are being searched + + AnimatedSearchBar( + value = query, + onValueChange = { query = it }, + controller = controller, + isLoading = isLoading, + onSearch = { text -> + // trigger real search + }, + config = AnimatedSearchBarConfig( + collapsedWidth = 56.dp, + expandedWidth = 300.dp, + height = 48.dp, + barBackgroundColor = Color.White, + barCornerRadius = 32.dp + ), + animationConfig = AnimatedSearchBarAnimationConfig( + widthSpringStiffness = Spring.StiffnessLow, + widthSpringDamping = Spring.DampingRatioMediumBouncy + ) + ) +} +``` + +
+
### 📚 **Learn More** @@ -765,4 +759,22 @@ Check out our [Contributing Guide](https://jetco.developerstring.com/community)
+

+ Contributors Card +

+ +

Thanks to all our amazing contributors ✨

+ +

+ + + +

+ + + diff --git a/assets/images/AnimatedSearchBar.gif b/assets/images/AnimatedSearchBar.gif new file mode 100644 index 0000000..a466ad0 Binary files /dev/null and b/assets/images/AnimatedSearchBar.gif differ diff --git a/jetco-android/JetCoLibrary/.idea/deploymentTargetSelector.xml b/jetco-android/JetCoLibrary/.idea/deploymentTargetSelector.xml index f90f923..b268ef3 100644 --- a/jetco-android/JetCoLibrary/.idea/deploymentTargetSelector.xml +++ b/jetco-android/JetCoLibrary/.idea/deploymentTargetSelector.xml @@ -4,14 +4,6 @@ diff --git a/jetco-android/JetCoLibrary/.idea/vcs.xml b/jetco-android/JetCoLibrary/.idea/vcs.xml index 94a25f7..fdf1fc8 100644 --- a/jetco-android/JetCoLibrary/.idea/vcs.xml +++ b/jetco-android/JetCoLibrary/.idea/vcs.xml @@ -1,6 +1,7 @@ + \ No newline at end of file diff --git a/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/AnimatedSearchBarPreview.kt b/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/AnimatedSearchBarPreview.kt new file mode 100644 index 0000000..d0c8e00 --- /dev/null +++ b/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/AnimatedSearchBarPreview.kt @@ -0,0 +1,145 @@ +package com.developerstring.jetco_library + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.developerstring.jetco.ui.components.search.animated_searchbar.AnimatedSearchBar +import com.developerstring.jetco.ui.components.search.animated_searchbar.AnimatedSearchBarController +import com.developerstring.jetco.ui.components.search.animated_searchbar.rememberAnimatedSearchBarController +import kotlinx.coroutines.launch + +/** + * Preview composable demonstrating [AnimatedSearchBar] usage and configurations. + * + * This preview simulates a full search workflow, including: + * - text input, + * - IME "Search" actions, + * - loading indicator state, + * - a fake asynchronous search result list, + * - and collapsing the bar when a result is selected. + * + * The preview is interactive and intended for both design-time and + * manual testing during development. + * + * ## Behaviors showcased: + * - Expanding/collapsing the search bar + * - Icon bounce + rotation animations + * - Loading spinner replacing the search icon + * - Clicking a search result: + * - sets the query text, + * - collapses the bar through the controller, + * - clears the results list. + * + * This example also demonstrates best practices for integrating + * [AnimatedSearchBar] into a real screen layout with state handling. + */ + +@Composable +fun AnimatedSearchBarPreview() { + var query by remember { mutableStateOf("") } + val controller = rememberAnimatedSearchBarController() + var results by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(false) } + var scope = rememberCoroutineScope() + + + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center + ) { + AnimatedSearchBar( + value = query, + onValueChange = { query = it }, + controller = controller, + isLoading = isLoading, + onSearch = { q -> + isLoading = true + scope.launch { + // tiny delay to simulate request + kotlinx.coroutines.delay(1200) + // produce fake results + results = if (q.isBlank()) emptyList() + else List(6) { index -> "${q.trim()} result ${index + 1}" } + isLoading = false + } + }, + onExpand = { /* optional: handle expand */ }, + onCollapse = { /* optional: handle collapse */ } + + ) + + Spacer(modifier = Modifier.height(16.dp)) + + //Simulate fake results for preview + if (results.isNotEmpty()) { + Text( + "Results", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(4.dp) + ) + + HorizontalDivider() + + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(results) { item -> + Row(modifier = Modifier + .fillMaxWidth() + .clickable { + // When user clicks a result: + // 1) set the query text + // 2) collapse the search bar via controller + query = item + controller.collapse() + // optional: clear results if you want them hidden + results = emptyList() + } + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically) { + Text(item, modifier = Modifier.weight(1f)) + Icon( + imageVector = Icons.Default.ArrowForward, contentDescription = null + ) + } + Divider() + } + } + } else { + // optional empty-state hint + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + "Type and press search to simulate results", + style = MaterialTheme.typography.bodySmall, + color = Color.Gray + ) + } + } + } + } +} diff --git a/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/MainActivity.kt b/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/MainActivity.kt index 67277a8..01ff854 100644 --- a/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/MainActivity.kt +++ b/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/MainActivity.kt @@ -18,7 +18,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { JetCoLibraryTheme { - CurvedCardPreview() + AnimatedSearchBarPreview() } } } diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/search/animated_searchbar/AnimatedSearchBar.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/search/animated_searchbar/AnimatedSearchBar.kt new file mode 100644 index 0000000..00e2ef6 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/search/animated_searchbar/AnimatedSearchBar.kt @@ -0,0 +1,330 @@ +package com.developerstring.jetco.ui.components.search.animated_searchbar + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch + +/** + * A modern animated search bar component with smooth expansion, + * bounce effects, loading indicators, and full user control through a configuration + * system and controller API. + * + * This composable provides a minimal collapsed circular search button that expands + * into a full search bar with text input, clear action, and optional loading state. + * The expansion uses customizable spring animations, scale pop effects, and animated + * width transitions. Developers can trigger collapse externally using the + * [AnimatedSearchBarController]. + * + * ## Features: + * - Smooth expand/collapse animations using spring physics + * - Icon bounce & rotation animation on expansion + * - Configurable width, height, colors, radii, padding, and corner styling + * - Optional loading indicator that replaces the search icon when searching + * - Clear button with optional collapse behavior + * - IME "Search" action callback support + * - External collapse control through [AnimatedSearchBarController] + * - Fully customizable through [AnimatedSearchBarConfig] and + * [AnimatedSearchBarAnimationConfig] + * + * ## Example Usage: + * ```kotlin + * val query by remember { mutableStateOf("") } + * val controller = rememberAnimatedSearchBarController() + * + * AnimatedSearchBar( + * value = query, + * onValueChange = { query = it }, + * controller = controller, + * onSearch = { text -> + * // trigger real search + * }, + * config = AnimatedSearchBarConfig( + * collapsedWidth = 56.dp, + * expandedWidth = 300.dp, + * height = 48.dp, + * barBackgroundColor = Color.White, + * barCornerRadius = 32.dp + * ), + * animationConfig = AnimatedSearchBarAnimationConfig( + * widthSpringStiffness = Spring.StiffnessLow, + * widthSpringDamping = Spring.DampingRatioMediumBouncy + * ) + * ) + * ``` + * ## Collapsing search bar programmatically + * You can collapse the bar manually using the controller: + * + * ```kotlin + * controller.collapse() + * ``` + * This is useful when the search results list captures a click event. + * + * @param value Current text inside the search input. + * @param onValueChange Callback invoked when the text input changes. + * @param modifier Modifier applied to the entire search bar layout. + * @param config Appearance configuration. See [AnimatedSearchBarConfig]. + * @param animationConfig Animation configuration. See [AnimatedSearchBarAnimationConfig]. + * @param isLoading Displays a circular loading indicator instead of the search icon when true. + * @param onSearch Callback triggered when the user presses IME Search. + * @param controller Controls external behavior such as collapsing the search bar. + * @param onExpand Callback invoked when the bar expands. + * @param onCollapse Callback invoked when the bar collapses. + * + * @see AnimatedSearchBarConfig for appearance configuration options + * @see AnimatedSearchBarAnimationConfig for animation configuration options + * @see [AnimatedSearchBarController] for external collapse control + * + */ + +@Composable +fun AnimatedSearchBar( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + config: AnimatedSearchBarConfig = AnimatedSearchBarConfig(), + animationConfig: AnimatedSearchBarAnimationConfig = AnimatedSearchBarAnimationConfig(), + isLoading: Boolean = false, + onSearch: (String) -> Unit = {}, + onExpand: () -> Unit = {}, + onCollapse: () -> Unit = {}, + controller: AnimatedSearchBarController = rememberAnimatedSearchBarController() +) { + var isExpanded by remember { mutableStateOf(false) } + + val iconScale = remember { Animatable(1f) } + + val scope = rememberCoroutineScope() + + fun triggerSearchIconBounceAnimation() { + scope.launch { + iconScale.animateTo( + 1.5f, animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ) + iconScale.animateTo( + 1f, animationSpec = spring( + dampingRatio = Spring.DampingRatioHighBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ) + } + } + + + val iconRotation by animateFloatAsState( + targetValue = if (isExpanded) 360f else 0f, animationSpec = tween( + durationMillis = 500, easing = LinearOutSlowInEasing + ) + ) + + val searchBarWidth by animateDpAsState( + targetValue = if (isExpanded) config.expandedWidth else config.collapsedWidth, + animationSpec = spring( + dampingRatio = animationConfig.widthSpringDamping, + stiffness = animationConfig.widthSpringStiffness + ) + ) + + val searchBarAlpha by animateFloatAsState( + targetValue = if (isExpanded) 1f else 0f, animationSpec = tween(durationMillis = 500) + ) + + val searchBarScale by animateFloatAsState( + targetValue = if (isExpanded) 1f else 0.8f, animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium + ) + ) + + val focusManager = LocalFocusManager.current + + LaunchedEffect(Unit) { + controller.collapseRequest = { + isExpanded = false + onCollapse() + triggerSearchIconBounceAnimation() + focusManager.clearFocus() + } + } + + Box( + modifier = modifier + .width(searchBarWidth) + .height(config.height) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .scale(searchBarScale) + .alpha(searchBarAlpha) + .background( + color = config.searchBarBackgroundColor, + shape = RoundedCornerShape(config.searchBarCornerRadius) + ) + .border( + width = config.searchBarBorderWidth, + color = config.searchBarBorderColor, + shape = RoundedCornerShape(config.searchBarCornerRadius) + ) + ) + + + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier + .size(config.height) + .padding(6.dp) + .scale(iconScale.value) + .rotate(iconRotation) + .background( + color = config.iconBackgroundColor, shape = CircleShape + ) + .clip(RoundedCornerShape(64.dp)) + .clickable { + if (!isExpanded) { + isExpanded = true + onExpand() + } + triggerSearchIconBounceAnimation() + }, contentAlignment = Alignment.Center + ) { + + val iconSize = config.height / 2 + + if (isExpanded && isLoading) { + CircularProgressIndicator( + strokeWidth = 2.dp, + color = config.iconTint, + modifier = Modifier.size(iconSize) + ) + } else { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = config.iconTint, + modifier = Modifier.size(iconSize) + ) + } + } + AnimatedVisibility( + visible = isExpanded, enter = fadeIn(tween(200)), exit = fadeOut(tween(150)) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + ) { + BasicTextField( + value = value, + onValueChange = { onValueChange(it) }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + onSearch(value) + focusManager.clearFocus() + }), + textStyle = TextStyle( + color = Color.Black, fontSize = 16.sp, lineHeight = 18.sp + ), + cursorBrush = SolidColor(Color.DarkGray), + decorationBox = { innerTextField -> + if (value.isEmpty()) { + Box( + modifier = Modifier, contentAlignment = Alignment.CenterStart + ) { + Text( + config.placeholderTextString, + color = config.placeholderTextColor, + fontSize = 16.sp, + modifier = Modifier.padding(start = 2.dp) + ) + } + } + innerTextField() + }) + + IconButton( + onClick = { + if (value.isNotEmpty()) { + onValueChange("") + focusManager.clearFocus() + } else { + onValueChange("") + isExpanded = false + onCollapse() + triggerSearchIconBounceAnimation() + } + }, modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Clear Search", + tint = config.clearIconTint, + modifier = Modifier.size(24.dp) + ) + } + } + } + } + } +} + diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/search/animated_searchbar/AnimatedSearchBarAnimationConfig.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/search/animated_searchbar/AnimatedSearchBarAnimationConfig.kt new file mode 100644 index 0000000..3ed8742 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/search/animated_searchbar/AnimatedSearchBarAnimationConfig.kt @@ -0,0 +1,47 @@ +package com.developerstring.jetco.ui.components.search.animated_searchbar + +import androidx.compose.animation.core.Spring + +/** + * Animation configuration class for [AnimatedSearchBar]. + * + * Controls spring physics, animation timings, and overshoot values for both the + * width expansion and the scale "pop" effect. + * + * @param rotationDuration Duration (in milliseconds) of the search-icon rotation + * that occurs during expansion. + * Default is **500ms**. + * + * @param bounceStiffness Spring stiffness applied to the icon bounce animation. + * Lower stiffness = softer bounce, higher stiffness = snappier. + * Default is **Spring.StiffnessMediumLow**. + * + * @param bounceDamping Spring damping ratio for bounce animation. + * Higher damping reduces oscillation. + * Default is **Spring.DampingRatioMediumBouncy**. + * + * @param widthSpringStiffness Spring stiffness for width expansion/collapse animation. + * Controls how fast the bar grows/shrinks. + * Default is **Spring.StiffnessLow**. + * + * @param widthSpringDamping Damping ratio applied during width animation. + * Controls how much overshoot or bounce occurs. + * Default is **Spring.DampingRatioLowBouncy**. + * + * @param fadeDuration Duration (in milliseconds) for fade-in / fade-out transitions + * used when showing or hiding content inside the expanded bar. + * Default is **200ms**. + * + * @see AnimatedSearchBarConfig for appearance configuration options + */ + +data class AnimatedSearchBarAnimationConfig( + val rotationDuration: Int = 500, + val bounceStiffness: Float = Spring.StiffnessMediumLow, + val bounceDamping: Float = Spring.DampingRatioMediumBouncy, + + val widthSpringStiffness: Float = Spring.StiffnessLow, + val widthSpringDamping: Float = Spring.DampingRatioLowBouncy, + + val fadeDuration: Int = 200, +) diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/search/animated_searchbar/AnimatedSearchBarConfig.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/search/animated_searchbar/AnimatedSearchBarConfig.kt new file mode 100644 index 0000000..c9ced13 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/search/animated_searchbar/AnimatedSearchBarConfig.kt @@ -0,0 +1,73 @@ +package com.developerstring.jetco.ui.components.search.animated_searchbar + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Configuration class for the visual appearance and behaviour of [AnimatedSearchBar]. + * + * Use this to tune sizes, colors, radii, spacing and other visual defaults. + * + * @param collapsedWidth Width of the bar when collapsed into a circular icon. + * Default is 64.dp. + * + * @param expandedWidth Target width when the search bar is fully expanded. + * Default is 320.dp. + * Note: The parent layout may still constrain this width. + * + * @param height Total height of the search bar, including icon and text field. + * Default is 48.dp. + * + * @param searchBarBackgroundColor Background color of the expanded search bar container. + * + * @param searchBarBorderWidth Width of the border around the expanded bar. + * Default is 2.dp. + * + * @param searchBarBorderColor Border color for the expanded bar. + * Only visible when `barBorderWidth` > 0.dp. + * + * @param searchBarCornerRadius Corner radius of the expanded search bar container. + * Default is 35.dp for a rounded-pill shape. + * + * @param iconBackgroundColor Background color of the circular icon container in collapsed mode. + * + * @param iconTint Tint color for the search icon inside the collapsed button. + * + * @param clearIconTint Tint color for the clear (✕) icon shown when expanded. + * + * @param placeholderTextColor Color used for placeholder text in the input field. + * + * @param placeholderTextString Text displayed when the input field is empty. Default is "Search". + * + * Example: + * ```kotlin + * AnimatedSearchBarConfig( + * collapsedWidth = 56.dp, + * expandedWidth = 320.dp, + * height = 48.dp, + * barBackgroundColor = Color.White, + * barCornerRadius = 28.dp + * ) + * ``` + * + * @see AnimatedSearchBar for usage + * @see AnimatedSearchBarAnimationConfig for animation configuration options + */ + +data class AnimatedSearchBarConfig( + val height: Dp = 48.dp, + val expandedWidth: Dp = 320.dp, + val collapsedWidth: Dp = 64.dp, + + val searchBarBackgroundColor: Color = Color.White, + val searchBarCornerRadius: Dp = 35.dp, + val searchBarBorderColor: Color = Color(0xFFE0E0E0), + val searchBarBorderWidth: Dp = 2.dp, + + val iconBackgroundColor: Color = Color(0xFF558B2F), + val iconTint: Color = Color.White, + val clearIconTint: Color = Color.Black, + val placeholderTextColor: Color = Color.Black, + val placeholderTextString: String = "Search" +) diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/search/animated_searchbar/AnimatedSearchBarController.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/search/animated_searchbar/AnimatedSearchBarController.kt new file mode 100644 index 0000000..90aa00d --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/search/animated_searchbar/AnimatedSearchBarController.kt @@ -0,0 +1,36 @@ +package com.developerstring.jetco.ui.components.search.animated_searchbar + +import androidx.compose.runtime.Stable + +/** + * Controller used to interact with [AnimatedSearchBar] from outside the component. + * + * A controller enables external UI elements (such as search result lists, navigation + * actions, or parent screens) to programmatically collapse the search bar without + * requiring direct state lifting. + * + * ## Usage Example + * ```kotlin + * val controller = rememberAnimatedSearchBarController() + * + * AnimatedSearchBar( + * value = query, + * onValueChange = { query = it }, + * controller = controller + * ) + * + * // Collapse from outside (e.g., when a result is clicked) + * controller.collapse() + * ``` + * Internally, [AnimatedSearchBar] registers a collapse request callback so the controller + * always triggers the same close animation and logic as the built-in clear button. + **/ + +@Stable +class AnimatedSearchBarController { + internal var collapseRequest: (() -> Unit)? = null + + fun collapse() { + collapseRequest?.invoke() + } +} \ No newline at end of file diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/search/animated_searchbar/RememberAnimatedSearchBarController.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/search/animated_searchbar/RememberAnimatedSearchBarController.kt new file mode 100644 index 0000000..fdd6a93 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/search/animated_searchbar/RememberAnimatedSearchBarController.kt @@ -0,0 +1,25 @@ +package com.developerstring.jetco.ui.components.search.animated_searchbar + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +/** + * Creates and remembers an instance of [AnimatedSearchBarController] for use with + * [AnimatedSearchBar]. + * + * The controller allows the parent UI to trigger collapse behavior externally, + * enabling scenarios where: + * - the user selects a search result, + * - a navigation event occurs, + * - or the screen must reset the search bar programmatically. + * + * ## Usage Example + * ```kotlin + * val controller = rememberAnimatedSearchBarController() + * ``` +**/ + +@Composable +fun rememberAnimatedSearchBarController(): AnimatedSearchBarController { + return remember { AnimatedSearchBarController() } +} diff --git a/jetco-android/JetCoLibrary/versions.gradle b/jetco-android/JetCoLibrary/versions.gradle index 6833afb..6d2476d 100644 --- a/jetco-android/JetCoLibrary/versions.gradle +++ b/jetco-android/JetCoLibrary/versions.gradle @@ -25,8 +25,8 @@ ext { library = [ groupId : "com.developerstring.jetco", - version_name : "1.0.0-beta.7", - version_code : 7, + version_name : "1.0.0-beta.9", + version_code : 9, target_sdk : 36, min_sdk : 21, compose_min_sdk: 21,