diff --git a/.gitignore b/.gitignore index aa724b7..55a1373 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,13 @@ *.iml .gradle /local.properties -/.idea/caches -/.idea/libraries -/.idea/modules.xml -/.idea/workspace.xml -/.idea/navEditor.xml +.idea/* /.idea/assetWizardSettings.xml .DS_Store /build +*/build/* /captures .externalNativeBuild .cxx local.properties +*.podspec diff --git a/app/build.gradle b/app/build.gradle index c510a5f..8c40486 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,6 +6,8 @@ plugins { android { compileSdk rootProject.compileSdk + namespace("com.gowtham.compose_ratingbar") + defaultConfig { applicationId "com.gowtham.compose_ratingbar" minSdk rootProject.minSdk @@ -42,7 +44,6 @@ android { composeOptions { kotlinCompilerExtensionVersion compose_compiler } - namespace 'com.gowtham.compose_ratingbar' } dependencies { @@ -62,6 +63,7 @@ dependencies { androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.2.1" - implementation project(path: ':ratingbar') + + implementation project(path: ':ratingbar-multiplatform') } \ No newline at end of file diff --git a/app/src/main/java/com/gowtham/compose_ratingbar/MainActivity.kt b/app/src/main/java/com/gowtham/compose_ratingbar/MainActivity.kt index 9042655..086c516 100644 --- a/app/src/main/java/com/gowtham/compose_ratingbar/MainActivity.kt +++ b/app/src/main/java/com/gowtham/compose_ratingbar/MainActivity.kt @@ -23,9 +23,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.gowtham.compose_ratingbar.MainActivity.Companion.initialRating import com.gowtham.compose_ratingbar.ui.theme.JetpackComposeTheme -import com.gowtham.ratingbar.RatingBar -import com.gowtham.ratingbar.RatingBarStyle -import com.gowtham.ratingbar.StepSize +import com.gowtham.compose_ratingbar_multiplatform.RatingBar +import com.gowtham.compose_ratingbar_multiplatform.RatingBarStyle +import com.gowtham.compose_ratingbar_multiplatform.StepSize + class MainActivity : ComponentActivity() { diff --git a/build.gradle b/build.gradle index 73217f7..ae49337 100644 --- a/build.gradle +++ b/build.gradle @@ -6,6 +6,7 @@ buildscript { minSdk = 21 targetSdk = 34 + compose_version = '1.5.1' compose_compiler = '1.5.3' @@ -20,6 +21,8 @@ plugins { id 'com.android.application' version '8.1.1' apply false id 'com.android.library' version '8.1.1' apply false id 'org.jetbrains.kotlin.android' version '1.9.10' apply false + id 'org.jetbrains.kotlin.multiplatform' version '1.9.10' apply false + id 'org.jetbrains.compose' version '1.5.3' apply false } task clean(type: Delete) { diff --git a/gradle.properties b/gradle.properties index 90d2e85..b399661 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,11 @@ android.useAndroidX=true android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official --Xopt-in=kotlin.RequiresOptIn +#-Xopt-in=kotlin.RequiresOptIn + +# Compose Multiplatform +org.jetbrains.compose.experimental.uikit.enabled=true +kotlin.native.cacheKind=none GROUP=io.github.a914-gowtham POM_ARTIFACT_ID=compose-ratingbar diff --git a/ratingbar-multiplatform/build.gradle.kts b/ratingbar-multiplatform/build.gradle.kts new file mode 100644 index 0000000..d0c0ed4 --- /dev/null +++ b/ratingbar-multiplatform/build.gradle.kts @@ -0,0 +1,73 @@ +plugins { + kotlin("multiplatform") + kotlin("native.cocoapods") + id("com.android.library") + id("org.jetbrains.compose") + id("maven-publish") +} + +@OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class) +kotlin { + targetHierarchy.default() + + android() + + iosX64() + iosArm64() + iosSimulatorArm64() + + jvm() + + cocoapods { + summary = "Some description for the Shared Module" + homepage = "Link to the Shared Module homepage" + version = "1.0" + ios.deploymentTarget = "14.1" + framework { + baseName = "ratingbar-multiplatform" + isStatic = true + } + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material) + + implementation("org.jetbrains.kotlinx:atomicfu:0.21.0") + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + } +} + +android { + namespace = "com.gowtham.compose_ratingbar_multiplatform" + compileSdk = 33 + defaultConfig { + minSdk = 21 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +afterEvaluate { + publishing { + publications.withType { + pom { + groupId = "com.gowtham.composeratingbar" + artifactId = "compose-ratingbar-multiplatform" + version = "1.0.0" + } + } + } +} \ No newline at end of file diff --git a/ratingbar-multiplatform/src/commonMain/kotlin/com/gowtham/compose_ratingbar_multiplatform/FractionalRectangleShape.kt b/ratingbar-multiplatform/src/commonMain/kotlin/com/gowtham/compose_ratingbar_multiplatform/FractionalRectangleShape.kt new file mode 100644 index 0000000..888dd46 --- /dev/null +++ b/ratingbar-multiplatform/src/commonMain/kotlin/com/gowtham/compose_ratingbar_multiplatform/FractionalRectangleShape.kt @@ -0,0 +1,31 @@ +package com.gowtham.compose_ratingbar_multiplatform + +import androidx.compose.runtime.Stable +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection + +@Stable +class FractionalRectangleShape( + private val startFraction: Float, + private val endFraction: Float +) : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + return Outline.Rectangle( + Rect( + left = (startFraction * size.width).coerceAtMost(size.width - 1f), + top = 0f, + right = (endFraction * size.width).coerceAtLeast(1f), + bottom = size.height + ) + ) + } + +} diff --git a/ratingbar-multiplatform/src/commonMain/kotlin/com/gowtham/compose_ratingbar_multiplatform/LogMessage.kt b/ratingbar-multiplatform/src/commonMain/kotlin/com/gowtham/compose_ratingbar_multiplatform/LogMessage.kt new file mode 100644 index 0000000..c790eda --- /dev/null +++ b/ratingbar-multiplatform/src/commonMain/kotlin/com/gowtham/compose_ratingbar_multiplatform/LogMessage.kt @@ -0,0 +1,15 @@ +package com.gowtham.compose_ratingbar_multiplatform + +object LogMessage { + + private const val logVisible = false + + internal fun v(msg: String) { + if (logVisible) println("Compose-Ratingbar :$msg") + } + + internal fun e(msg: String) { + if (logVisible) println("Compose-Ratingbar: $msg") + } + +} \ No newline at end of file diff --git a/ratingbar-multiplatform/src/commonMain/kotlin/com/gowtham/compose_ratingbar_multiplatform/PathExtensions.kt b/ratingbar-multiplatform/src/commonMain/kotlin/com/gowtham/compose_ratingbar_multiplatform/PathExtensions.kt new file mode 100644 index 0000000..2b601c2 --- /dev/null +++ b/ratingbar-multiplatform/src/commonMain/kotlin/com/gowtham/compose_ratingbar_multiplatform/PathExtensions.kt @@ -0,0 +1,48 @@ +package com.gowtham.compose_ratingbar_multiplatform + +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Path +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +fun Path.addStar( + size: Size, + spikes: Int = 5, + outerRadiusFraction: Float = 0.5f, + innerRadiusFraction: Float = 0.2f +): Path { + val outerRadius = size.minDimension * outerRadiusFraction + val innerRadius = size.minDimension * innerRadiusFraction + + val centerX = size.width / 2 + val centerY = size.height / 2 + + var totalAngle = PI / 2 // Since we start at the top center, the initial angle will be 90° + val degreesPerSection = (2 * PI) / spikes + + moveTo(centerX, 0f) // Starts at the top center of the bounds + + var x: Double + var y: Double + + for (i in 1..spikes) { + // Line going inwards from outerCircle to innerCircle + totalAngle += degreesPerSection / 2 + x = centerX + cos(totalAngle) * innerRadius + y = centerY - sin(totalAngle) * innerRadius + lineTo(x.toFloat(), y.toFloat()) + + + // Line going outwards from innerCircle to outerCircle + totalAngle += degreesPerSection / 2 + x = centerX + cos(totalAngle) * outerRadius + y = centerY - sin(totalAngle) * outerRadius + lineTo(x.toFloat(), y.toFloat()) + } + + // Path should be closed to ensure it's not an open shape + close() + + return this +} diff --git a/ratingbar-multiplatform/src/commonMain/kotlin/com/gowtham/compose_ratingbar_multiplatform/RatingBar.kt b/ratingbar-multiplatform/src/commonMain/kotlin/com/gowtham/compose_ratingbar_multiplatform/RatingBar.kt new file mode 100644 index 0000000..bf7030e --- /dev/null +++ b/ratingbar-multiplatform/src/commonMain/kotlin/com/gowtham/compose_ratingbar_multiplatform/RatingBar.kt @@ -0,0 +1,262 @@ +package com.gowtham.compose_ratingbar_multiplatform + +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize + +sealed interface StepSize { + object ONE : StepSize + object HALF : StepSize +} + +sealed class RatingBarStyle(open val activeColor: Color) { + companion object { + val Default = Stroke() + } + + open class Fill( + override val activeColor: Color = Color(0xFFFFCA00), + val inActiveColor: Color = Color(0x66FFCA00), + ) : RatingBarStyle(activeColor) + + /** + * @param width width for each star + * @param color A border [Color] shown on inactive star. + */ + class Stroke( + val width: Float = 1f, + override val activeColor: Color = Color(0xFFFFCA00), + val strokeColor: Color = Color(0xFF888888) + ) : RatingBarStyle(activeColor) +} + +//For ui testing +val StarRatingKey = SemanticsPropertyKey("StarRating") +var SemanticsPropertyReceiver.starRating by StarRatingKey + + +/** + * @param value is current selected rating count + * @param numOfStars count of stars to be shown. + * @param size size for each star + * @param spaceBetween padding between each star. + * @param isIndicator isIndicator Whether this rating bar is only an indicator or the value is changeable on user interaction. + * @param stepSize Can be [StepSize.ONE] or [StepSize.HALF] + * @param hideInactiveStars Whether the inactive stars should be hidden. + * @param style the different style applied to the Rating Bar. + * @param onRatingChanged A function to be called when the click or drag is released and rating value is passed + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun RatingBar( + value: Float, + modifier: Modifier = Modifier, + numOfStars: Int = 5, + size: Dp = 32.dp, + spaceBetween: Dp = 6.dp, + isIndicator: Boolean = false, + stepSize: StepSize = StepSize.ONE, + hideInactiveStars: Boolean = false, + style: RatingBarStyle = RatingBarStyle.Default, + painterEmpty: Painter? = null, + painterFilled: Painter? = null, + onValueChange: (Float) -> Unit, + onRatingChanged: (Float) -> Unit +) { + var rowSize by remember { mutableStateOf(Size.Zero) } + var lastDraggedValue by remember { mutableStateOf(0f) } + val direction = LocalLayoutDirection.current + val density = LocalDensity.current + + + val paddingInPx = remember { + with(density) { spaceBetween.toPx() } + } + val starSizeInPx = remember { + with(density) { size.toPx() } + } + + Row(modifier = modifier + .onSizeChanged { rowSize = it.toSize() } + .pointerInput( + Unit + ) { + //handling dragging events + detectHorizontalDragGestures( + onDragEnd = { + if (isIndicator || hideInactiveStars) + return@detectHorizontalDragGestures + onRatingChanged(lastDraggedValue) + }, + onDragCancel = { + + }, + onDragStart = { + + }, + onHorizontalDrag = { change, _ -> + if (isIndicator || hideInactiveStars) + return@detectHorizontalDragGestures + change.consume() + val dragX = change.position.x.coerceIn(-1f, rowSize.width) + var calculatedStars = + RatingBarUtils.calculateStars( + dragX, + paddingInPx, + numOfStars, stepSize, starSizeInPx + ) + + if (direction == LayoutDirection.Rtl) + calculatedStars = numOfStars - calculatedStars + onValueChange(calculatedStars) + lastDraggedValue = calculatedStars + } + ) + }) { + ComposeStars( + value, + numOfStars, + size, + spaceBetween, + hideInactiveStars, + style = style, + painterEmpty, + painterFilled + ) + } +} + +@Composable +fun RatingBar( + value: Float, + modifier: Modifier = Modifier, + numOfStars: Int = 5, + size: Dp = 32.dp, + spaceBetween: Dp = 6.dp, + isIndicator: Boolean = false, + stepSize: StepSize = StepSize.ONE, + hideInactiveStars: Boolean = false, + style: RatingBarStyle, + onValueChange: (Float) -> Unit, + onRatingChanged: (Float) -> Unit +) { + RatingBar( + value = value, + modifier = modifier, + numOfStars = numOfStars, + size = size, + spaceBetween = spaceBetween, + isIndicator = isIndicator, + stepSize = stepSize, + hideInactiveStars = hideInactiveStars, + style = style, + painterEmpty = null, + painterFilled = null, + onValueChange = onValueChange, + onRatingChanged = onRatingChanged + ) +} + +@Composable +fun RatingBar( + value: Float, + modifier: Modifier = Modifier, + numOfStars: Int = 5, + size: Dp = 32.dp, + spaceBetween: Dp = 6.dp, + isIndicator: Boolean = false, + stepSize: StepSize = StepSize.ONE, + hideInactiveStars: Boolean = false, + painterEmpty: Painter, + painterFilled: Painter, + onValueChange: (Float) -> Unit, + onRatingChanged: (Float) -> Unit +) { + RatingBar( + value = value, + modifier = modifier, + numOfStars = numOfStars, + size = size, + spaceBetween = spaceBetween, + isIndicator = isIndicator, + stepSize = stepSize, + hideInactiveStars = hideInactiveStars, + style = RatingBarStyle.Default, + painterEmpty = painterEmpty, + painterFilled = painterFilled, + onValueChange = onValueChange, + onRatingChanged = onRatingChanged + ) +} + +@Composable +fun ComposeStars( + value: Float, + numOfStars: Int, + size: Dp, + spaceBetween: Dp, + hideInactiveStars: Boolean, + style: RatingBarStyle, + painterEmpty: Painter?, + painterFilled: Painter? +) { + + val ratingPerStar = 1f + var remainingRating = value + + Row(modifier = Modifier + .semantics { starRating = value }) { + for (i in 1..numOfStars) { + val starRating = when { + remainingRating == 0f -> { + 0f + } + + remainingRating >= ratingPerStar -> { + remainingRating -= ratingPerStar + 1f + } + + else -> { + val fraction = remainingRating / ratingPerStar + remainingRating = 0f + fraction + } + } + if (hideInactiveStars && starRating == 0.0f) + break + + RatingStar( + fraction = starRating, + style = style, + modifier = Modifier + .padding( + start = if (i > 1) spaceBetween else 0.dp, + end = if (i < numOfStars) spaceBetween else 0.dp + ) + .size(size = size) + .testTag("RatingStar"), + painterEmpty = painterEmpty, painterFilled = painterFilled + ) + } + } +} \ No newline at end of file diff --git a/ratingbar-multiplatform/src/commonMain/kotlin/com/gowtham/compose_ratingbar_multiplatform/RatingBarUtils.kt b/ratingbar-multiplatform/src/commonMain/kotlin/com/gowtham/compose_ratingbar_multiplatform/RatingBarUtils.kt new file mode 100644 index 0000000..b86aa89 --- /dev/null +++ b/ratingbar-multiplatform/src/commonMain/kotlin/com/gowtham/compose_ratingbar_multiplatform/RatingBarUtils.kt @@ -0,0 +1,36 @@ +package com.gowtham.compose_ratingbar_multiplatform + +object RatingBarUtils { + + fun calculateStars( + draggedX: Float, + horizontalPaddingInPx: Float, + numOfStars: Int, + stepSize: StepSize, + starSizeInPx: Float, + ): Float { + + if(draggedX<=0){ + return 0f + } + + val starWidthWithRightPadding = starSizeInPx + (2 * horizontalPaddingInPx) + val halfStarWidth = starSizeInPx / 2 + for (i in 1..numOfStars) { + if (draggedX < (i * starWidthWithRightPadding)) { + return if (stepSize is StepSize.ONE) { + i.toFloat() + } else { + val crossedStarsWidth = (i - 1) * starWidthWithRightPadding + val remainingWidth = draggedX - crossedStarsWidth + if (remainingWidth <= halfStarWidth) { + i.toFloat().minus(0.5f) + } else { + i.toFloat() + } + } + } + } + return 0f + } +} \ No newline at end of file diff --git a/ratingbar-multiplatform/src/commonMain/kotlin/com/gowtham/compose_ratingbar_multiplatform/RatingStar.kt b/ratingbar-multiplatform/src/commonMain/kotlin/com/gowtham/compose_ratingbar_multiplatform/RatingStar.kt new file mode 100644 index 0000000..21a62fd --- /dev/null +++ b/ratingbar-multiplatform/src/commonMain/kotlin/com/gowtham/compose_ratingbar_multiplatform/RatingStar.kt @@ -0,0 +1,115 @@ +package com.gowtham.compose_ratingbar_multiplatform + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection + +@Composable +fun RatingStar( + fraction: Float, + modifier: Modifier = Modifier, + style: RatingBarStyle, + painterEmpty: Painter?, + painterFilled: Painter? +) { + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + Box(modifier = modifier) { + FilledStar( + fraction, + style, + isRtl, + painterFilled + ) + EmptyStar(fraction, style, isRtl, painterEmpty) + } +} + +@Composable +private fun FilledStar( + fraction: Float, style: RatingBarStyle, isRtl: Boolean, painterFilled: Painter? +) = Canvas( + modifier = Modifier + .fillMaxSize() + .clip( + if (isRtl) rtlFilledStarFractionalShape(fraction = fraction) + else FractionalRectangleShape(0f, fraction) + ) +) { + + if (painterFilled != null) { + with(painterFilled) { + draw( + size = Size(size.height, size.height), + ) + } + } else { + val path = Path().addStar(size) + + drawPath(path, color = style.activeColor, style = Fill) // Filled Star + drawPath( + path, + color = style.activeColor, + style = Stroke(width = if (style is RatingBarStyle.Stroke) style.width else 1f) + ) // Border + } + + +} + +@Composable +private fun EmptyStar( + fraction: Float, + style: RatingBarStyle, + isRtl: Boolean, + painterEmpty: Painter? +) = + Canvas( + modifier = Modifier + .fillMaxSize() + .clip( + if (isRtl) rtlEmptyStarFractionalShape(fraction = fraction) + else FractionalRectangleShape(fraction, 1f) + ) + ) { + + if (painterEmpty != null) { + with(painterEmpty) { + draw( + size = Size(size.height, size.height), + ) + } + } else { + val path = Path().addStar(size) + if (style is RatingBarStyle.Fill) drawPath( + path, + color = style.inActiveColor, + style = Fill + ) // Border + else if (style is RatingBarStyle.Stroke) drawPath( + path, color = style.strokeColor, style = Stroke(width = style.width) + ) // Border + } + + } + +fun rtlEmptyStarFractionalShape(fraction: Float): FractionalRectangleShape { + return if (fraction == 1f || fraction == 0f) + FractionalRectangleShape(fraction, 1f) + else FractionalRectangleShape(0f, 1f - fraction) +} + +fun rtlFilledStarFractionalShape(fraction: Float): FractionalRectangleShape { + return if (fraction == 0f || fraction == 1f) + FractionalRectangleShape(0f, fraction) + else FractionalRectangleShape(1f - fraction, 1f) +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index d18a54f..670d68c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -25,4 +25,4 @@ signing.password=Gowthamsj10 signing.secretKeyRingFile=/home/gowtham/gpg-keys/secring.gpg mavenCentralUsername=Gowtham24 mavenCentralPassword=ENZ2QjVEWB%Sfx. -*/ \ No newline at end of file +*/ include ':ratingbar-multiplatform'