forked from GitHub-Mirror/riotX-android
Merge pull request #135 from vector-im/feature/reaction_timeline_ux
Reactions: Display existing reactions below the message
This commit is contained in:
commit
6aae943e77
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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>()
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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!!
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
13
vector/src/main/res/drawable/rounded_rect_shape.xml
Normal file
13
vector/src/main/res/drawable/rounded_rect_shape.xml
Normal 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>
|
13
vector/src/main/res/drawable/rounded_rect_shape_off.xml
Normal file
13
vector/src/main/res/drawable/rounded_rect_shape_off.xml
Normal 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>
|
@ -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>
|
66
vector/src/main/res/layout/reaction_button.xml
Normal file
66
vector/src/main/res/layout/reaction_button.xml
Normal 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>
|
@ -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>
|
13
vector/src/main/res/values/values.xml
Normal file
13
vector/src/main/res/values/values.xml
Normal 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>
|
Loading…
Reference in New Issue
Block a user