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 "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
implementation 'androidx.appcompat:appcompat:1.1.0-alpha03' 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 'androidx.core:core-ktx:1.0.1'
implementation 'com.jakewharton.threetenabp:threetenabp:1.1.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 package im.vector.riotredesign.core.utils
import android.view.View 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 package im.vector.riotredesign.features.home.room.detail
import android.annotation.SuppressLint
import android.app.Activity.RESULT_OK import android.app.Activity.RESULT_OK
import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
@ -25,9 +27,11 @@ import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.text.Editable import android.text.Editable
import android.text.Spannable import android.text.Spannable
import android.text.TextUtils
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog 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.model.message.*
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.user.model.User 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.R
import im.vector.riotredesign.core.dialogs.DialogListItem import im.vector.riotredesign.core.dialogs.DialogListItem
import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer 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.ImageMediaViewerActivity
import im.vector.riotredesign.features.media.VideoContentRenderer import im.vector.riotredesign.features.media.VideoContentRenderer
import im.vector.riotredesign.features.media.VideoMediaViewerActivity import im.vector.riotredesign.features.media.VideoMediaViewerActivity
import im.vector.riotredesign.features.reactions.EmojiReactionPickerActivity
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_room_detail.* import kotlinx.android.synthetic.main.fragment_room_detail.*
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@ -115,6 +119,26 @@ class RoomDetailFragment :
setArguments(args) 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>() private val session by inject<Session>()
@ -445,6 +469,11 @@ class RoomDetailFragment :
override fun onAvatarClicked(informationData: MessageInformationData) { override fun onAvatarClicked(informationData: MessageInformationData) {
vectorBaseActivity.notImplemented() vectorBaseActivity.notImplemented()
} }
@SuppressLint("SetTextI18n")
override fun onMemberNameClicked(informationData: MessageInformationData) {
insertUserDisplayNameInTextEditor(informationData.memberName?.toString())
}
// AutocompleteUserPresenter.Callback // AutocompleteUserPresenter.Callback
override fun onQueryUsers(query: CharSequence?) { 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 onEventCellClicked(informationData: MessageInformationData, messageContent: MessageContent, view: View)
fun onEventLongClicked(informationData: MessageInformationData, messageContent: MessageContent, view: View): Boolean fun onEventLongClicked(informationData: MessageInformationData, messageContent: MessageContent, view: View): Boolean
fun onAvatarClicked(informationData: MessageInformationData) fun onAvatarClicked(informationData: MessageInformationData)
fun onMemberNameClicked(informationData: MessageInformationData)
} }
private val collapsedEventIds = linkedSetOf<String>() private val collapsedEventIds = linkedSetOf<String>()

View File

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

View File

@ -16,12 +16,20 @@
package im.vector.riotredesign.features.home.room.detail.timeline.item package im.vector.riotredesign.features.home.room.detail.timeline.item
import android.os.Build
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView 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 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.home.AvatarRenderer
import im.vector.riotredesign.features.reactions.widget.ReactionButton
abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() { abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
@ -37,6 +45,9 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
@EpoxyAttribute @EpoxyAttribute
var avatarClickListener: View.OnClickListener? = null var avatarClickListener: View.OnClickListener? = null
@EpoxyAttribute
var memberClickListener: View.OnClickListener? = null
override fun bind(holder: H) { override fun bind(holder: H) {
super.bind(holder) super.bind(holder)
if (informationData.showInformation) { if (informationData.showInformation) {
@ -46,15 +57,17 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
height = size height = size
width = size width = size
} }
holder.avatarImageView.visibility = View.VISIBLE holder.avatarImageView.visibility = View.VISIBLE
holder.avatarImageView.setOnClickListener(avatarClickListener) holder.avatarImageView.setOnClickListener(avatarClickListener)
holder.memberNameView.visibility = View.VISIBLE holder.memberNameView.visibility = View.VISIBLE
holder.memberNameView.setOnClickListener(memberClickListener)
holder.timeView.visibility = View.VISIBLE holder.timeView.visibility = View.VISIBLE
holder.timeView.text = informationData.time holder.timeView.text = informationData.time
holder.memberNameView.text = informationData.memberName holder.memberNameView.text = informationData.memberName
AvatarRenderer.render(informationData.avatarUrl, informationData.senderId, informationData.memberName?.toString(), holder.avatarImageView) AvatarRenderer.render(informationData.avatarUrl, informationData.senderId, informationData.memberName?.toString(), holder.avatarImageView)
} else { } else {
holder.avatarImageView.setOnClickListener(null)
holder.memberNameView.setOnClickListener(null)
holder.avatarImageView.visibility = View.GONE holder.avatarImageView.visibility = View.GONE
holder.memberNameView.visibility = View.GONE holder.memberNameView.visibility = View.GONE
holder.timeView.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.setOnClickListener(cellClickListener)
holder.view.setOnLongClickListener(longClickListener) 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() { 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 avatarImageView by bind<ImageView>(R.id.messageAvatarImageView)
val memberNameView by bind<TextView>(R.id.messageMemberNameView) val memberNameView by bind<TextView>(R.id.messageMemberNameView)
val timeView by bind<TextView>(R.id.messageTimeView) 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 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.View
import android.view.ViewStub import android.view.ViewStub
import androidx.annotation.IdRes import androidx.annotation.IdRes
@ -24,6 +22,7 @@ import androidx.constraintlayout.widget.Guideline
import im.vector.riotredesign.R import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.VectorEpoxyHolder import im.vector.riotredesign.core.epoxy.VectorEpoxyHolder
import im.vector.riotredesign.core.epoxy.VectorEpoxyModel import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
import im.vector.riotredesign.core.utils.DimensionUtils.dpToPx
abstract class BaseEventItem<H : BaseEventItem.BaseHolder> : VectorEpoxyModel<H>() { abstract class BaseEventItem<H : BaseEventItem.BaseHolder> : VectorEpoxyModel<H>() {
@ -70,13 +69,5 @@ abstract class BaseEventItem<H : BaseEventItem.BaseHolder> : VectorEpoxyModel<H>
SMALL(30), SMALL(30),
NONE(0) 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 time: CharSequence? = null,
val avatarUrl: String?, val avatarUrl: String?,
val memberName: CharSequence? = null, 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 ) : Parcelable

View File

@ -20,10 +20,12 @@ import android.media.ExifInterface
import android.net.Uri import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
import android.widget.ImageView import android.widget.ImageView
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.github.piasy.biv.view.BigImageView import com.github.piasy.biv.view.BigImageView
import im.vector.matrix.android.api.Matrix import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.session.content.ContentUrlResolver import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.riotredesign.core.glide.GlideApp import im.vector.riotredesign.core.glide.GlideApp
import im.vector.riotredesign.core.utils.DimensionUtils.dpToPx
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import java.io.File import java.io.File
@ -67,6 +69,7 @@ object ImageContentRenderer {
.with(imageView) .with(imageView)
.load(resolvedUrl) .load(resolvedUrl)
.dontAnimate() .dontAnimate()
.transform(RoundedCorners(dpToPx(8,imageView.context)))
.thumbnail(0.3f) .thumbnail(0.3f)
.into(imageView) .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:paddingLeft="8dp"
android:paddingRight="8dp"> android:paddingRight="8dp">
<ImageView <ImageView
android:id="@+id/messageAvatarImageView" android:id="@+id/messageAvatarImageView"
android:layout_width="44dp" android:layout_width="44dp"
@ -28,7 +27,7 @@
<TextView <TextView
android:id="@+id/messageMemberNameView" android:id="@+id/messageMemberNameView"
android:layout_width="0dp" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
@ -39,8 +38,10 @@
android:maxLines="1" android:maxLines="1"
android:textSize="15sp" android:textSize="15sp"
android:textStyle="bold" android:textStyle="bold"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toStartOf="@+id/messageTimeView" 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_constraintStart_toEndOf="@id/messageStartGuideline"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/full_names" /> tools:text="@tools:sample/full_names" />
@ -48,14 +49,15 @@
<TextView <TextView
android:id="@+id/messageTimeView" android:id="@+id/messageTimeView"
android:layout_width="0dp" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:textColor="@color/brown_grey" android:textColor="@color/brown_grey"
android:textSize="12sp"
app:layout_constraintBaseline_toBaselineOf="@id/messageMemberNameView"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toEndOf="@id/messageMemberNameView"
app:layout_constraintTop_toTopOf="@id/messageMemberNameView"
tools:text="@tools:sample/date/hhmm" /> tools:text="@tools:sample/date/hhmm" />
@ -80,4 +82,116 @@
tools:ignore="MissingConstraints" /> 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> </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"> <style name="TimelineContentStubLayoutParams" parent="TimelineContentStubBaseParams">
<item name="layout_constraintTop_toBottomOf">@id/messageMemberNameView</item> <item name="layout_constraintTop_toBottomOf">@id/messageMemberNameView</item>
<item name="layout_constraintBottom_toTopOf">@id/messageBottomInfo</item>
</style> </style>
</resources> </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>