Merge pull request #135 from vector-im/feature/reaction_timeline_ux

Reactions: Display existing reactions below the message
This commit is contained in:
Valere 2019-05-15 11:24:00 +02:00 committed by GitHub
commit 6aae943e77
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1102 additions and 21 deletions

View File

@ -135,7 +135,7 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
implementation 'androidx.appcompat:appcompat:1.1.0-alpha03'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta1'
implementation 'androidx.core:core-ktx:1.0.1'
implementation 'com.jakewharton.threetenabp:threetenabp:1.1.1'

View File

@ -1,3 +1,18 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.utils
import android.view.View

View File

@ -0,0 +1,31 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.core.utils
import android.content.Context
import android.util.TypedValue
object DimensionUtils {
fun dpToPx(dp: Int, context: Context): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
dp.toFloat(),
context.resources.displayMetrics
).toInt()
}
}

View File

@ -16,7 +16,9 @@
package im.vector.riotredesign.features.home.room.detail
import android.annotation.SuppressLint
import android.app.Activity.RESULT_OK
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
@ -25,9 +27,11 @@ import android.os.Bundle
import android.os.Parcelable
import android.text.Editable
import android.text.Spannable
import android.text.TextUtils
import android.view.HapticFeedbackConstants
import android.view.LayoutInflater
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
@ -52,7 +56,6 @@ import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotredesign.features.reactions.EmojiReactionPickerActivity
import im.vector.riotredesign.R
import im.vector.riotredesign.core.dialogs.DialogListItem
import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer
@ -82,6 +85,7 @@ import im.vector.riotredesign.features.media.ImageContentRenderer
import im.vector.riotredesign.features.media.ImageMediaViewerActivity
import im.vector.riotredesign.features.media.VideoContentRenderer
import im.vector.riotredesign.features.media.VideoMediaViewerActivity
import im.vector.riotredesign.features.reactions.EmojiReactionPickerActivity
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_room_detail.*
import org.koin.android.ext.android.inject
@ -115,6 +119,26 @@ class RoomDetailFragment :
setArguments(args)
}
}
/**
* Sanitize the display name.
*
* @param displayName the display name to sanitize
* @return the sanitized display name
*/
fun sanitizeDisplayname(displayName: String): String? {
var displayName = displayName
// sanity checks
if (!TextUtils.isEmpty(displayName)) {
val ircPattern = " (IRC)"
if (displayName.endsWith(ircPattern)) {
displayName = displayName.substring(0, displayName.length - ircPattern.length)
}
}
return displayName
}
}
private val session by inject<Session>()
@ -445,6 +469,11 @@ class RoomDetailFragment :
override fun onAvatarClicked(informationData: MessageInformationData) {
vectorBaseActivity.notImplemented()
}
@SuppressLint("SetTextI18n")
override fun onMemberNameClicked(informationData: MessageInformationData) {
insertUserDisplayNameInTextEditor(informationData.memberName?.toString())
}
// AutocompleteUserPresenter.Callback
override fun onQueryUsers(query: CharSequence?) {
@ -514,4 +543,50 @@ class RoomDetailFragment :
}
}
}
//utils
/**
* Insert an user displayname in the message editor.
*
* @param text the text to insert.
*/
private fun insertUserDisplayNameInTextEditor(text: String?) {
if (null != text) {
// var vibrate = false
val myDisplayName = session.getUser(session.sessionParams.credentials.userId)?.displayName
if (TextUtils.equals(myDisplayName, text)) {
// current user
if (TextUtils.isEmpty(composerEditText.text)) {
composerEditText.append(Command.EMOTE.command + " ")
composerEditText.setSelection(composerEditText.text.length)
// vibrate = true
}
} else {
// another user
if (TextUtils.isEmpty(composerEditText.text)) {
// Ensure displayName will not be interpreted as a Slash command
if (text.startsWith("/")) {
composerEditText.append("\\")
}
composerEditText.append(sanitizeDisplayname(text)!! + ": ")
} else {
composerEditText.text.insert(composerEditText.selectionStart, sanitizeDisplayname(text)!! + " ")
}
// vibrate = true
}
// if (vibrate && PreferencesManager.vibrateWhenMentioning(context)) {
// val v= context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
// if (v?.hasVibrator() == true) {
// v.vibrate(100)
// }
// }
composerEditText.requestFocus()
val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(composerEditText, InputMethodManager.SHOW_FORCED)
}
}
}

View File

@ -57,6 +57,7 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
fun onEventCellClicked(informationData: MessageInformationData, messageContent: MessageContent, view: View)
fun onEventLongClicked(informationData: MessageInformationData, messageContent: MessageContent, view: View): Boolean
fun onAvatarClicked(informationData: MessageInformationData)
fun onMemberNameClicked(informationData: MessageInformationData)
}
private val collapsedEventIds = linkedSetOf<String>()

View File

@ -81,6 +81,9 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
memberName = formattedMemberName,
showInformation = showInformation)
//Test for reactions UX
//informationData.orderedReactionList = listOf( Triple("👍",1,false), Triple("👎",2,false))
// val all = event.root.toContent()
// val ev = all.toModel<Event>()
return when (messageContent) {
@ -105,6 +108,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.memberClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onMemberNameClicked(informationData)
}))
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
@ -129,6 +136,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.memberClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onMemberNameClicked(informationData)
}))
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
@ -170,6 +181,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.memberClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onMemberNameClicked(informationData)
}))
.clickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onImageMessageClicked(messageContent, data, view)
@ -212,6 +227,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.memberClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onMemberNameClicked(informationData)
}))
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
@ -239,6 +258,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.memberClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onMemberNameClicked(informationData)
}))
//click on the text
.clickListener(
DebouncedClickListener(View.OnClickListener { view ->
@ -272,6 +295,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.memberClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onMemberNameClicked(informationData)
}))
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
@ -296,6 +323,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.memberClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onMemberNameClicked(informationData)
}))
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)

View File

@ -16,12 +16,20 @@
package im.vector.riotredesign.features.home.room.detail.timeline.item
import android.os.Build
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import im.vector.riotredesign.R
import androidx.constraintlayout.helper.widget.Flow
import androidx.core.view.children
import androidx.core.view.isGone
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import im.vector.riotredesign.R
import im.vector.riotredesign.core.utils.DimensionUtils.dpToPx
import im.vector.riotredesign.features.home.AvatarRenderer
import im.vector.riotredesign.features.reactions.widget.ReactionButton
abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
@ -37,6 +45,9 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
@EpoxyAttribute
var avatarClickListener: View.OnClickListener? = null
@EpoxyAttribute
var memberClickListener: View.OnClickListener? = null
override fun bind(holder: H) {
super.bind(holder)
if (informationData.showInformation) {
@ -46,15 +57,17 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
height = size
width = size
}
holder.avatarImageView.visibility = View.VISIBLE
holder.avatarImageView.setOnClickListener(avatarClickListener)
holder.memberNameView.visibility = View.VISIBLE
holder.memberNameView.setOnClickListener(memberClickListener)
holder.timeView.visibility = View.VISIBLE
holder.timeView.text = informationData.time
holder.memberNameView.text = informationData.memberName
AvatarRenderer.render(informationData.avatarUrl, informationData.senderId, informationData.memberName?.toString(), holder.avatarImageView)
} else {
holder.avatarImageView.setOnClickListener(null)
holder.memberNameView.setOnClickListener(null)
holder.avatarImageView.visibility = View.GONE
holder.memberNameView.visibility = View.GONE
holder.timeView.visibility = View.GONE
@ -62,6 +75,30 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
holder.view.setOnClickListener(cellClickListener)
holder.view.setOnLongClickListener(longClickListener)
if (informationData.orderedReactionList.isNullOrEmpty()) {
holder.reactionWrapper.isVisible = false
} else {
holder.reactionWrapper.isVisible = true
//clear all reaction buttons (but not the Flow helper!)
holder.reactionWrapper.children.forEach { (it as? ReactionButton)?.isGone = true }
val idToRefInFlow = ArrayList<Int>()
informationData.orderedReactionList?.forEachIndexed { index, reaction ->
(holder.reactionWrapper.children.elementAt(index) as? ReactionButton)?.let { reactionButton ->
reactionButton.isVisible = true
idToRefInFlow.add(reactionButton.id)
reactionButton.reactionString = reaction.first
reactionButton.reactionCount = reaction.second
reactionButton.setChecked(reaction.third)
}
}
// Just setting the view as gone will break the FlowHelper (and invisible will take too much space),
// so have to update ref ids
holder.reactionFlowHelper.referencedIds = idToRefInFlow.toIntArray()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 && !holder.view.isInLayout) {
holder.reactionFlowHelper.requestLayout()
}
}
}
protected fun View.renderSendState() {
@ -74,6 +111,9 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView)
val memberNameView by bind<TextView>(R.id.messageMemberNameView)
val timeView by bind<TextView>(R.id.messageTimeView)
val reactionWrapper: ViewGroup by bind(R.id.messageBottomInfo)
val reactionFlowHelper: Flow by bind(R.id.reactionsFlowHelper)
}
}

View File

@ -15,8 +15,6 @@
*/
package im.vector.riotredesign.features.home.room.detail.timeline.item
import android.content.Context
import android.util.TypedValue
import android.view.View
import android.view.ViewStub
import androidx.annotation.IdRes
@ -24,6 +22,7 @@ import androidx.constraintlayout.widget.Guideline
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.VectorEpoxyHolder
import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
import im.vector.riotredesign.core.utils.DimensionUtils.dpToPx
abstract class BaseEventItem<H : BaseEventItem.BaseHolder> : VectorEpoxyModel<H>() {
@ -70,13 +69,5 @@ abstract class BaseEventItem<H : BaseEventItem.BaseHolder> : VectorEpoxyModel<H>
SMALL(30),
NONE(0)
}
fun dpToPx(dp: Int, context: Context): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
dp.toFloat(),
context.resources.displayMetrics
).toInt()
}
}
}

View File

@ -29,5 +29,7 @@ data class MessageInformationData(
val time: CharSequence? = null,
val avatarUrl: String?,
val memberName: CharSequence? = null,
val showInformation: Boolean = true
val showInformation: Boolean = true,
/*List of reactions (emoji,count,isSelected)*/
var orderedReactionList: List<Triple<String,Int,Boolean>>? = null
) : Parcelable

View File

@ -20,10 +20,12 @@ import android.media.ExifInterface
import android.net.Uri
import android.os.Parcelable
import android.widget.ImageView
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.github.piasy.biv.view.BigImageView
import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.riotredesign.core.glide.GlideApp
import im.vector.riotredesign.core.utils.DimensionUtils.dpToPx
import kotlinx.android.parcel.Parcelize
import java.io.File
@ -67,6 +69,7 @@ object ImageContentRenderer {
.with(imageView)
.load(resolvedUrl)
.dontAnimate()
.transform(RoundedCorners(dpToPx(8,imageView.context)))
.thumbnail(0.3f)
.into(imageView)
}

View File

@ -0,0 +1,140 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.reactions.widget
import android.animation.ArgbEvaluator
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.util.Property
import android.view.View
/**
* This view is responsible for drawing big circle that will pulse when clicked
* As describe in http://frogermcs.github.io/twitters-like-animation-in-android-alternative/
*/
class CircleView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) {
var startColor = -0xa8de
var endColor = -0x3ef9
private val argbEvaluator = ArgbEvaluator()
private val circlePaint = Paint()
private val maskPaint = Paint()
private var tempBitmap: Bitmap? = null
private var tempCanvas: Canvas? = null
var outerCircleRadiusProgress = 0f
set(value) {
field = value
updateCircleColor()
postInvalidate()
}
var innerCircleRadiusProgress = 0f
set(value) {
field = value
postInvalidate()
}
private var maxCircleSize: Int = 0
init {
circlePaint.style = Paint.Style.FILL
maskPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
maxCircleSize = w / 2
tempBitmap = Bitmap.createBitmap(width, width, Bitmap.Config.ARGB_8888)
tempCanvas = Canvas(tempBitmap)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
tempCanvas!!.drawColor(0xffffff, PorterDuff.Mode.CLEAR)
tempCanvas!!.drawCircle(width / 2f, height / 2f, outerCircleRadiusProgress * maxCircleSize, circlePaint)
tempCanvas!!.drawCircle(width / 2f, height / 2f, innerCircleRadiusProgress * maxCircleSize, maskPaint)
canvas.drawBitmap(tempBitmap, 0f, 0f, null)
}
// fun setInnerCircleRadiusProgress(innerCircleRadiusProgress: Float) {
// this.innerCircleRadiusProgress = innerCircleRadiusProgress
// postInvalidate()
// }
// fun getInnerCircleRadiusProgress(): Float {
// return innerCircleRadiusProgress
// }
// fun setOuterCircleRadiusProgress(outerCircleRadiusProgress: Float) {
// this.outerCircleRadiusProgress = outerCircleRadiusProgress
// updateCircleColor()
// postInvalidate()
// }
private fun updateCircleColor() {
var colorProgress = clamp(outerCircleRadiusProgress, 0.5f, 1f) as Float
colorProgress = mapValueFromRangeToRange(colorProgress, 0.5f, 1f, 0f, 1f)
this.circlePaint.color = argbEvaluator.evaluate(colorProgress, startColor, endColor) as Int
}
// fun getOuterCircleRadiusProgress(): Float {
// return outerCircleRadiusProgress
// }
companion object {
val INNER_CIRCLE_RADIUS_PROGRESS: Property<CircleView, Float> = object : Property<CircleView, Float>(Float::class.java, "innerCircleRadiusProgress") {
override operator fun get(`object`: CircleView): Float? {
return `object`.innerCircleRadiusProgress
}
override operator fun set(`object`: CircleView, value: Float?) {
value?.let {
`object`.innerCircleRadiusProgress = it
}
}
}
val OUTER_CIRCLE_RADIUS_PROGRESS: Property<CircleView, Float> = object : Property<CircleView, Float>(Float::class.java, "outerCircleRadiusProgress") {
override operator fun get(`object`: CircleView): Float? {
return `object`.outerCircleRadiusProgress
}
override operator fun set(`object`: CircleView, value: Float?) {
value?.let {
`object`.outerCircleRadiusProgress = it
}
}
}
fun mapValueFromRangeToRange(value: Float, fromLow: Float, fromHigh: Float, toLow: Float, toHigh: Float): Float {
return toLow + (value - fromLow) / (fromHigh - fromLow) * (toHigh - toLow)
}
fun clamp(value: Float, low: Float, high: Float): Float {
return Math.min(Math.max(value, low), high)
}
}
}

View File

@ -0,0 +1,201 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.reactions.widget
import android.animation.ArgbEvaluator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import android.util.Property
import android.view.View
/**
* This view will draw dots floating around the center of it's view
* As describe in http://frogermcs.github.io/twitters-like-animation-in-android-alternative/
*/
class DotsView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) {
private var COLOR_1 = -0x3ef9
private var COLOR_2 = -0x6800
private var COLOR_3 = -0xa8de
private var COLOR_4 = -0xbbcca
private val circlePaints = arrayOfNulls<Paint>(4)
private var centerX: Int = 0
private var centerY: Int = 0
private var maxOuterDotsRadius: Float = 0.toFloat()
private var maxInnerDotsRadius: Float = 0.toFloat()
private var maxDotSize: Float = 0.toFloat()
var currentProgress = 0f
set(value) {
field = value
updateInnerDotsPosition()
updateOuterDotsPosition()
updateDotsPaints()
updateDotsAlpha()
postInvalidate()
}
private var currentRadius1 = 0f
private var currentDotSize1 = 0f
private var currentDotSize2 = 0f
private var currentRadius2 = 0f
private val argbEvaluator = ArgbEvaluator()
init {
for (i in circlePaints.indices) {
circlePaints[i] = Paint()
circlePaints[i]!!.style = Paint.Style.FILL
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
centerX = w / 2
centerY = h / 2
maxDotSize = 3f
maxOuterDotsRadius = w / 2 - maxDotSize * 2
maxInnerDotsRadius = 0.8f * maxOuterDotsRadius
}
override fun onDraw(canvas: Canvas) {
drawOuterDotsFrame(canvas)
drawInnerDotsFrame(canvas)
}
private fun drawOuterDotsFrame(canvas: Canvas) {
for (i in 0 until DOTS_COUNT) {
val cX = (centerX + currentRadius1 * Math.cos(i.toDouble() * OUTER_DOTS_POSITION_ANGLE.toDouble() * Math.PI / 180)).toFloat()
val cY = (centerY + currentRadius1 * Math.sin(i.toDouble() * OUTER_DOTS_POSITION_ANGLE.toDouble() * Math.PI / 180)).toFloat()
canvas.drawCircle(cX, cY, currentDotSize1, circlePaints[i % circlePaints.size])
}
}
private fun drawInnerDotsFrame(canvas: Canvas) {
for (i in 0 until DOTS_COUNT) {
val cX = (centerX + currentRadius2 * Math.cos((i * OUTER_DOTS_POSITION_ANGLE - 10) * Math.PI / 180)).toFloat()
val cY = (centerY + currentRadius2 * Math.sin((i * OUTER_DOTS_POSITION_ANGLE - 10) * Math.PI / 180)).toFloat()
canvas.drawCircle(cX, cY, currentDotSize2, circlePaints[(i + 1) % circlePaints.size])
}
}
// fun setCurrentProgress(currentProgress: Float) {
// this.currentProgress = currentProgress
//
// updateInnerDotsPosition()
// updateOuterDotsPosition()
// updateDotsPaints()
// updateDotsAlpha()
//
// postInvalidate()
// }
//
// fun getCurrentProgress(): Float {
// return currentProgress
// }
private fun updateInnerDotsPosition() {
if (currentProgress < 0.3f) {
this.currentRadius2 = CircleView.mapValueFromRangeToRange(currentProgress, 0f, 0.3f, 0f, maxInnerDotsRadius)
} else {
this.currentRadius2 = maxInnerDotsRadius
}
if (currentProgress < 0.2) {
this.currentDotSize2 = maxDotSize
} else if (currentProgress < 0.5) {
this.currentDotSize2 = CircleView.mapValueFromRangeToRange(
currentProgress, 0.2f, 0.5f, maxDotSize, 0.3f * maxDotSize)
} else {
this.currentDotSize2 = CircleView.mapValueFromRangeToRange(
currentProgress, 0.5f, 1f, maxDotSize * 0.3f, 0f)
}
}
fun setColors(primary: Int, secondary: Int) {
COLOR_1 = primary
COLOR_2 = secondary
COLOR_3 = primary
COLOR_4 = secondary
}
private fun updateOuterDotsPosition() {
if (currentProgress < 0.3f) {
this.currentRadius1 = CircleView.mapValueFromRangeToRange(
currentProgress, 0.0f, 0.3f, 0f, maxOuterDotsRadius * 0.8f)
} else {
this.currentRadius1 = CircleView.mapValueFromRangeToRange(
currentProgress, 0.3f, 1f, 0.8f * maxOuterDotsRadius, maxOuterDotsRadius)
}
if (currentProgress < 0.7) {
this.currentDotSize1 = maxDotSize
} else {
this.currentDotSize1 = CircleView.mapValueFromRangeToRange(
currentProgress, 0.7f, 1f, maxDotSize, 0f)
}
}
private fun updateDotsPaints() {
if (currentProgress < 0.5f) {
val progress = CircleView.mapValueFromRangeToRange(currentProgress, 0f, 0.5f, 0f, 1f) as Float
circlePaints[0]?.color = argbEvaluator.evaluate(progress, COLOR_1, COLOR_2) as Int
circlePaints[1]?.color = argbEvaluator.evaluate(progress, COLOR_2, COLOR_3) as Int
circlePaints[2]?.color = argbEvaluator.evaluate(progress, COLOR_3, COLOR_4) as Int
circlePaints[3]?.color = argbEvaluator.evaluate(progress, COLOR_4, COLOR_1) as Int
} else {
val progress = CircleView.mapValueFromRangeToRange(currentProgress, 0.5f, 1f, 0f, 1f) as Float
circlePaints[0]?.color = argbEvaluator.evaluate(progress, COLOR_2, COLOR_3) as Int
circlePaints[1]?.color = argbEvaluator.evaluate(progress, COLOR_3, COLOR_4) as Int
circlePaints[2]?.color = argbEvaluator.evaluate(progress, COLOR_4, COLOR_1) as Int
circlePaints[3]?.color = argbEvaluator.evaluate(progress, COLOR_1, COLOR_2) as Int
}
}
private fun updateDotsAlpha() {
val progress = CircleView.clamp(currentProgress, 0.6f, 1f) as Float
val alpha = (CircleView.mapValueFromRangeToRange(progress, 0.6f, 1f, 255f, 0f) as? Float)?.toInt()
?: 0
circlePaints.forEach { it?.alpha = alpha }
}
companion object {
private val DOTS_COUNT = 7
private val OUTER_DOTS_POSITION_ANGLE = 360 / DOTS_COUNT
val DOTS_PROGRESS: Property<DotsView, Float> = object : Property<DotsView, Float>(Float::class.java, "dotsProgress") {
override operator fun get(`object`: DotsView): Float? {
return `object`.currentProgress
}
override operator fun set(`object`: DotsView, value: Float?) {
`object`.currentProgress = value!!
}
}
}
}

View File

@ -0,0 +1,331 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.reactions.widget
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.content.Context
import android.content.res.TypedArray
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.DecelerateInterpolator
import android.view.animation.OvershootInterpolator
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import im.vector.riotredesign.R
/**
* An animated reaction button.
* Displays a String reaction (emoji), with a count, and that can be selected or not (toggle)
*/
class ReactionButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
defStyleAttr: Int = 0) : FrameLayout(context, attrs, defStyleAttr), View.OnClickListener {
companion object {
private val DECCELERATE_INTERPOLATOR = DecelerateInterpolator()
private val ACCELERATE_DECELERATE_INTERPOLATOR = AccelerateDecelerateInterpolator()
private val OVERSHOOT_INTERPOLATOR = OvershootInterpolator(4f)
}
private var emojiView: TextView? = null
private var countTextView: TextView? = null
private var reactionSelector: View? = null
private var dotsView: DotsView
private var circleView: CircleView
var reactedListener: ReactedListener? = null
private var dotPrimaryColor: Int = 0
private var dotSecondaryColor: Int = 0
private var circleStartColor: Int = 0
private var circleEndColor: Int = 0
var reactionCount = 11
set(value) {
field = value
countTextView?.text = value.toString()
}
var reactionString = "😀"
set(value) {
field = value
emojiView?.text = field
}
private var animationScaleFactor: Float = 0.toFloat()
private var isChecked: Boolean = false
private var animatorSet: AnimatorSet? = null
private var onDrawable: Drawable? = null
private var offDrawable: Drawable? = null
init {
LayoutInflater.from(getContext()).inflate(R.layout.reaction_button, this, true)
emojiView = findViewById(R.id.reactionText)
dotsView = findViewById(R.id.dots)
circleView = findViewById(R.id.circle)
reactionSelector = findViewById(R.id.reactionSelector)
countTextView = findViewById(R.id.reactionCount)
countTextView?.text = reactionCount.toString()
val array = context.obtainStyledAttributes(attrs, R.styleable.ReactionButton, defStyleAttr, 0)
onDrawable = ContextCompat.getDrawable(context, R.drawable.rounded_rect_shape)
offDrawable = ContextCompat.getDrawable(context, R.drawable.rounded_rect_shape_off)
circleStartColor = array.getColor(R.styleable.ReactionButton_circle_start_color, 0)
if (circleStartColor != 0)
circleView.startColor = circleStartColor
circleEndColor = array.getColor(R.styleable.ReactionButton_circle_end_color, 0)
if (circleEndColor != 0)
circleView.endColor = circleEndColor
dotPrimaryColor = array.getColor(R.styleable.ReactionButton_dots_primary_color, 0)
dotSecondaryColor = array.getColor(R.styleable.ReactionButton_dots_secondary_color, 0)
if (dotPrimaryColor != 0 && dotSecondaryColor != 0) {
dotsView.setColors(dotPrimaryColor, dotSecondaryColor)
}
array.getString(R.styleable.ReactionButton_emoji)?.let {
reactionString = it
}
reactionCount = array.getInt(R.styleable.ReactionButton_reaction_count, 0)
val status = array.getBoolean(R.styleable.ReactionButton_toggled, false)
setChecked(status)
setOnClickListener(this)
array.recycle()
}
private fun getDrawableFromResource(array: TypedArray, styleableIndexId: Int): Drawable? {
val id = array.getResourceId(styleableIndexId, -1)
return if (-1 != id) ContextCompat.getDrawable(context, id) else null
}
/**
* This triggers the entire functionality of the button such as icon changes,
* animations, listeners etc.
*
* @param v
*/
override fun onClick(v: View) {
if (!isEnabled)
return
isChecked = !isChecked
//icon!!.setImageDrawable(if (isChecked) likeDrawable else unLikeDrawable)
reactionSelector?.background = if (isChecked) onDrawable else offDrawable
if (isChecked) {
reactedListener?.onReacted(this)
} else {
reactedListener?.onUnReacted(this)
}
if (animatorSet != null) {
animatorSet!!.cancel()
}
if (isChecked) {
emojiView!!.animate().cancel()
emojiView!!.scaleX = 0f
emojiView!!.scaleY = 0f
circleView.innerCircleRadiusProgress = 0f
circleView.outerCircleRadiusProgress = 0f
dotsView.currentProgress = 0f
animatorSet = AnimatorSet()
val outerCircleAnimator = ObjectAnimator.ofFloat(circleView, CircleView.OUTER_CIRCLE_RADIUS_PROGRESS, 0.1f, 1f)
outerCircleAnimator.duration = 250
outerCircleAnimator.interpolator = DECCELERATE_INTERPOLATOR
val innerCircleAnimator = ObjectAnimator.ofFloat(circleView, CircleView.INNER_CIRCLE_RADIUS_PROGRESS, 0.1f, 1f)
innerCircleAnimator.duration = 200
innerCircleAnimator.startDelay = 200
innerCircleAnimator.interpolator = DECCELERATE_INTERPOLATOR
val starScaleYAnimator = ObjectAnimator.ofFloat(emojiView, ImageView.SCALE_Y, 0.2f, 1f)
starScaleYAnimator.duration = 350
starScaleYAnimator.startDelay = 250
starScaleYAnimator.interpolator = OVERSHOOT_INTERPOLATOR
val starScaleXAnimator = ObjectAnimator.ofFloat(emojiView, ImageView.SCALE_X, 0.2f, 1f)
starScaleXAnimator.duration = 350
starScaleXAnimator.startDelay = 250
starScaleXAnimator.interpolator = OVERSHOOT_INTERPOLATOR
val dotsAnimator = ObjectAnimator.ofFloat(dotsView, DotsView.DOTS_PROGRESS, 0f, 1f)//.ofFloat<DotsView>(dotsView, DotsView.DOTS_PROGRESS, 0, 1f)
dotsAnimator.duration = 900
dotsAnimator.startDelay = 50
dotsAnimator.interpolator = ACCELERATE_DECELERATE_INTERPOLATOR
animatorSet!!.playTogether(
outerCircleAnimator,
innerCircleAnimator,
starScaleYAnimator,
starScaleXAnimator,
dotsAnimator
)
animatorSet!!.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationCancel(animation: Animator) {
circleView.innerCircleRadiusProgress = 0f
circleView.outerCircleRadiusProgress = 0f
dotsView.currentProgress = 0f
emojiView!!.scaleX = 1f
emojiView!!.scaleY = 1f
}
override fun onAnimationEnd(animation: Animator) {
// if (animationEndListener != null) {
// // animationEndListener!!.onAnimationEnd(this@ReactionButton)
// }
}
})
animatorSet!!.start()
}
}
/**
* Used to trigger the scale animation that takes places on the
* icon when the button is touched.
*
* @param event
* @return
*/
override fun onTouchEvent(event: MotionEvent): Boolean {
if (!isEnabled)
return true
when (event.action) {
MotionEvent.ACTION_DOWN ->
/*
Commented out this line and moved the animation effect to the action up event due to
conflicts that were occurring when library is used in sliding type views.
icon.animate().scaleX(0.7f).scaleY(0.7f).setDuration(150).setInterpolator(DECCELERATE_INTERPOLATOR);
*/
isPressed = true
MotionEvent.ACTION_MOVE -> {
val x = event.x
val y = event.y
val isInside = x > 0 && x < width && y > 0 && y < height
if (isPressed != isInside) {
isPressed = isInside
}
}
MotionEvent.ACTION_UP -> {
emojiView!!.animate().scaleX(0.7f).scaleY(0.7f).setDuration(150).interpolator = DECCELERATE_INTERPOLATOR
emojiView!!.animate().scaleX(1f).scaleY(1f).interpolator = DECCELERATE_INTERPOLATOR
if (isPressed) {
performClick()
isPressed = false
}
}
MotionEvent.ACTION_CANCEL -> isPressed = false
}
return true
}
/**
* This set sets the colours that are used for the little dots
* that will be exploding once the like button is clicked.
*
* @param primaryColor
* @param secondaryColor
*/
fun setExplodingDotColorsRes(@ColorRes primaryColor: Int, @ColorRes secondaryColor: Int) {
dotsView.setColors(ContextCompat.getColor(context, primaryColor), ContextCompat.getColor(context, secondaryColor))
}
fun setExplodingDotColorsInt(@ColorInt primaryColor: Int, @ColorInt secondaryColor: Int) {
dotsView.setColors(primaryColor, secondaryColor)
}
fun setCircleStartColorRes(@ColorRes circleStartColor: Int) {
this.circleStartColor = ContextCompat.getColor(context, circleStartColor)
circleView.startColor = this.circleStartColor
}
fun setCircleStartColorInt(@ColorInt circleStartColor: Int) {
this.circleStartColor = circleStartColor
circleView.startColor = circleStartColor
}
fun setCircleEndColorRes(@ColorRes circleEndColor: Int) {
this.circleEndColor = ContextCompat.getColor(context, circleEndColor)
circleView.endColor = this.circleEndColor
}
/**
* Sets the initial state of the button to liked
* or unliked.
*
* @param status
*/
fun setChecked(status: Boolean?) {
if (status!!) {
isChecked = true
reactionSelector?.background = onDrawable
} else {
isChecked = false
reactionSelector?.background = offDrawable
}
}
/**
* Sets the factor by which the dots should be sized.
*/
fun setAnimationScaleFactor(animationScaleFactor: Float) {
this.animationScaleFactor = animationScaleFactor
}
interface ReactedListener {
fun onReacted(reactionButton: ReactionButton)
fun onUnReacted(reactionButton: ReactionButton)
}
}

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<size android:width="40dp" android:height="22dp"/>
<solid android:color="@color/light_blue_grey" />
<stroke android:width="1dp" android:color="@color/accent_color_light" />
<corners android:radius="20dp" />
</shape>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<size android:width="40dp" android:height="22dp"/>
<solid android:color="@color/light_blue_grey" />
<stroke android:width="1dp" android:color="@color/list_divider_color_light" />
<corners android:radius="20dp" />
</shape>

View File

@ -9,7 +9,6 @@
android:paddingLeft="8dp"
android:paddingRight="8dp">
<ImageView
android:id="@+id/messageAvatarImageView"
android:layout_width="44dp"
@ -28,7 +27,7 @@
<TextView
android:id="@+id/messageMemberNameView"
android:layout_width="0dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
@ -39,8 +38,10 @@
android:maxLines="1"
android:textSize="15sp"
android:textStyle="bold"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toStartOf="@+id/messageTimeView"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toEndOf="@id/messageStartGuideline"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/full_names" />
@ -48,14 +49,15 @@
<TextView
android:id="@+id/messageTimeView"
android:layout_width="0dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:textColor="@color/brown_grey"
android:textSize="12sp"
app:layout_constraintBaseline_toBaselineOf="@id/messageMemberNameView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintTop_toTopOf="@id/messageMemberNameView"
app:layout_constraintStart_toEndOf="@id/messageMemberNameView"
tools:text="@tools:sample/date/hhmm" />
@ -80,4 +82,116 @@
tools:ignore="MissingConstraints" />
<!-- TODO: For now we show 8 reactions maximum, this will need rework when needed-->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/messageBottomInfo"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/messageStartGuideline"
app:layout_constraintVertical_chainStyle="packed"
tools:visibility="visible">
<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="👍"
tools:ignore="MissingConstraints"
tools:reaction_count="3"
tools:visibility="visible" />
<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="👎"
tools:ignore="MissingConstraints"
tools:reaction_count="10"
tools:visibility="visible" />
<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="😀"
tools:ignore="MissingConstraints"
tools:visibility="visible" />
<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="☹️"
tools:ignore="MissingConstraints"
tools:visibility="visible" />
<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="😱"
tools:ignore="MissingConstraints"
tools:visibility="visible" />
<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction6"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="❌"
tools:ignore="MissingConstraints"
tools:visibility="visible" />
<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction7"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="✔️"
tools:ignore="MissingConstraints"
tools:visibility="visible" />
<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction8"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="♥️"
tools:ignore="MissingConstraints"
tools:visibility="visible" />
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/reactionsFlowHelper"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="2dp"
app:constraint_referenced_ids="messageBottomReaction1,messageBottomReaction2,messageBottomReaction3,messageBottomReaction4,messageBottomReaction5,messageBottomReaction6,messageBottomReaction7,messageBottomReaction8"
app:flow_horizontalBias="0"
app:flow_horizontalGap="8dp"
app:flow_horizontalStyle="packed"
app:flow_verticalBias="0"
app:flow_verticalGap="4dp"
app:flow_wrapMode="chain"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:clipChildren="false"
android:layout_width="44dp"
android:layout_height="26dp">
<View
android:id="@+id/reactionSelector"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/rounded_rect_shape" />
<im.vector.riotredesign.features.reactions.widget.DotsView
android:id="@+id/dots"
android:layout_width="30dp"
android:clipChildren="false"
android:layout_height="30dp"
app:layout_constraintBottom_toBottomOf="@+id/reactionText"
app:layout_constraintStart_toStartOf="@+id/reactionText"
app:layout_constraintTop_toTopOf="@+id/reactionText"
app:layout_constraintEnd_toEndOf="@+id/reactionText"/>
<im.vector.riotredesign.features.reactions.widget.CircleView
android:id="@+id/circle"
android:layout_width="14dp"
android:layout_height="14dp"
app:layout_constraintBottom_toBottomOf="@+id/reactionText"
app:layout_constraintStart_toStartOf="@+id/reactionText"
app:layout_constraintTop_toTopOf="@+id/reactionText"
app:layout_constraintEnd_toEndOf="@+id/reactionText"/>
<TextView
android:id="@+id/reactionText"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_gravity="center"
android:layout_marginStart="6dp"
android:layout_marginLeft="6dp"
android:gravity="center"
tools:text="👍"
android:textSize="13sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView
android:id="@+id/reactionCount"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginEnd="6dp"
android:layout_marginRight="6dp"
android:gravity="center"
android:maxLines="1"
android:textColor="?android:attr/textColorSecondary"
android:textSize="10sp"
android:textStyle="bold"
app:autoSizeMaxTextSize="14sp"
app:autoSizeMinTextSize="8sp"
app:autoSizeTextType="uniform"
app:layout_constraintEnd_toEndOf="parent"
tools:text="10" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -266,5 +266,6 @@
<style name="TimelineContentStubLayoutParams" parent="TimelineContentStubBaseParams">
<item name="layout_constraintTop_toBottomOf">@id/messageMemberNameView</item>
<item name="layout_constraintBottom_toTopOf">@id/messageBottomInfo</item>
</style>
</resources>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="ReactionButton">
<attr name="dots_primary_color" format="color|reference" />
<attr name="dots_secondary_color" format="color|reference" />
<attr name="circle_start_color" format="color|reference" />
<attr name="circle_end_color" format="color|reference" />
<attr name="toggled" format="boolean" />
<attr name="emoji" format="string"/>
<attr name="reaction_count" format="integer"/>
</declare-styleable>
</resources>