I am working on a custom view, TreeView, which would display a tree given a root node. I am using the Kotlin language.
Here's what part of it looks like at the moment:
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
drawNodeAndChildren(canvas, rootNode)
}
/**
* Draws a representation of a node and its children onto the canvas.
* ...
*/
private fun drawNodeAndChildren(canvas: Canvas, node: TreeNode<Person>, ...): Int {
...
// Calculate coordinates of rect
val top = ...
val bottom = ...
val left = ...
val right = ...
// Draw node representation
val rect = RectF(left, top, right, bottom)
canvas.drawRect(rect, nodePaint)
// Draw line to connect to parent
...
// Repeat for children
...
}
This works successfully, and I am able to produce a visual tree with rectangles representing nodes and lines between parents and children.
However, I want to be able to display an image and text of the person. I have written a compound view called PersonView which is able to do this given a Person object. Ideally, in the future, the user would be able to click on these PersonViews in the TreeView to trigger an action.
So essentially, instead of rectangles, PersonViews would be drawn.
I have tried creating the view programmatically and setting its layout params like so:
...
val personView = PersonView(context).apply { person = node.data }
val layoutParams = ViewGroup.LayoutParams(
(right - left).toInt(), // width
(bottom - top).toInt() // height
)
personView.layoutParams = layoutParams
personView.x = left
personView.y = top
personView.draw(canvas)
...
I've also tried:
...
val personView = PersonView(context).apply { person = node.data }
personView.layout(left.toInt(), top.toInt(), right.toInt(), bottom.toInt())
personView.draw(canvas)
...
In both cases, nothing is drawn instead of the rectangles - it's as if the two code samples above has no effect.
N.B. the lines between where the rectangles would be (i.e. connections between nodes) is drawn, and this part of the code has not been altered.
So my question is how do I draw a custom view inside another custom view so that I can get the effect I have described?
Related
I'm having an issue with this for two days now... I have a binding and an image to pop when clicking a button.
I want that every time the button is pressed another image will pop either next to the pervious one, or, if there's no space in the linear layout, that it will pop ontop of the previous one.
The idea is to get a card from a card deck and put it ot the hand of the player.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityPlayTableBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
var image = findViewById<ImageView>(R.id.fight_deck)
var i: Int = 1
val deck = Deck()
var deckListOfCards: MutableList<Card> = deck.shuffledDeckOfCard()
val deck_clickable_top = findViewById<ImageButton>(R.id.deck_button)
val imageView = ImageView(this)
val Width = convertDpToPixel(135f, this)
val Height = convertDpToPixel(190f, this)
imageView.layoutParams = LinearLayout.LayoutParams(Width.toInt(), Height.toInt())
var imgResId: Int = 0
deck_clickable_top.setOnClickListener {
if (deckListOfCards.size != 0) {
imgResId = deckListOfCards.get(0).getCardFace()
i += 1
deck.removeCardFromDeck(deckListOfCards.get(0))
}
if (deckListOfCards.size == 0) {
deck_clickable_top.visibility = View.INVISIBLE
deck_clickable_top.layoutParams.height = 0
}
imageView.setImageResource(imgResId)
}
binding.bottomLinearLayout.addView(imageView)
}
I can't figure out how to do it...
Well, LinearLayout doesn't seem like a good choice for this, but you can try something along these lines:
// step 1 - this is the linear layout you'll use as the container
val containerBinding = CustomLinearLayoutContainerBinding.inflate(inflater, container, false)
// step 2 - on some click event you can create a new binding, this should contain you image
val componentBinding = CustomComponentLayoutBinding.inflate(inflater, container, false).apply {
// set the image drawable here, text, any other data in here
}
// step 3 - the root layout of that binding is the linear layout
containerBinding.root.addView(componentBinding.root)
// the following 2 methods of LinearLayout can be very handy
removeAllViews() // to remove all previously added children
invalidate() // to re-render the linear layout
But please note that everything you want to do with those views after you added them will need manual changes and again, this doesn't feel like you should go for it. However, I found this link, check out this implementation, feels like this suits your requirements better.
In my XML I'm just declaring a ChipGroup as follows:
<com.google.android.material.chip.ChipGroup
android:id="#+id/chipGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
And then adding each Chip dynamically (where CustomChipFilterStyle styles them as a "filter" type of Chip):
ChipGroup chipGroup = findViewById(R.id.chipGroup);
for (String name : names) {
Chip chip = new Chip(this, null, R.attr.CustomChipFilterStyle);
chip.setText(name);
chipGroup.addView(chip);
}
In the guidance (see the video clip under "Movable") it suggests that "Input chips can be reordered or moved into other fields":
But I can't see any guidance about how this is done, or find any examples out there. Is it a completely bespoke thing (via View.OnDragListener and chip.setOnDragListener()), or are there utility methods for this as part of the Chip framework? All I really need to be able to do is to reorder Chips within the same ChipGroup. I did start with chip.setOnDragListener() but soon realised I didn't have sufficient knowledge about how to create the necessary animations to nudge and re-order other Chips as the Chip itself is being dragged (and to distinguish between a tap -- to filter -- and a drag)... and I hoped that there might be some out-of-the-box way of doing this with a ChipGroup like is maybe alluded to in the above guidance.
But I can't see any guidance about how [chip reordering within a ChipGroup] is done, or find any examples out there.
It is surprising that there doesn't seem to be an "out-of-the-box" way to reorder chips in a ChipGroup - at least not one that I have found.
All I really need to be able to do is to reorder Chips within the same ChipGroup.
I did start with chip.setOnDragListener() but soon realised I didn't have sufficient knowledge about how to create the necessary animations to nudge and re-order other Chips as the Chip itself is being dragged
The following doesn't really fully answer your question since the answer involves a RecyclerView and not a ChipGroup, but the effect is the same. This solution is based up the ItemTouchHelper Demo
by Paul Burke. I have converted the Java to Kotlin and made some modifications to the code. I have posted a demo repo at ChipReorder The layout manager I use for the RecyclerView is FlexboxLayoutManager.
The demo app relies upon ItemTouchHelper which is a utility class that adds swipe to dismiss and drag & drop support to RecyclerView. If you look at the actual code of ItemTouchHelper, you will get an idea of the underlying complexity of the animation that appears on the screen for a simple drag.
Here is a quick video of chips being dragged around using the demo app.
I believe that any functionality that you may need from ChipGroup can be implemented through RecyclerView or its adapter.
Update: I have added a module to the demo repo called "chipgroupreorder" which reorders chips within a ChipGroup with animation. Although this looks much the same as the RecyclerView solution, it uses a ChipGroup and not a RecyclerView.
The demo uses a View.OnDragListener and relies upon android:animateLayoutChanges="true" that is set for the ChipGroup for the animations.
The selection of which view to shift is rudimentary and can be improved. There are probably other issues that may arise upon further testing.
As you suggested there's no out-of-the-box solution for this. So I've made a sample project to show usage of setOnDragListener & how you can create something like this for yourself.
Note: This is far from being the perfect polished solution that you might expect but I believe it can nudge you in the right direction.
Complete code: https://github.com/mayurgajra/ChipsDragAndDrop
Output:
Pasting code here as well with inline comments:
MainActivity
class MainActivity : AppCompatActivity() {
private val dragMessage = "Chip Added"
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
val names = mutableListOf("Name 1", "Name 2", "Name 3")
for (name in names) {
val chip = Chip(this, null, 0)
chip.text = name
binding.chipGroup1.addView(chip)
}
attachChipDragListener()
binding.chipGroup1.setOnDragListener(chipDragListener)
}
private val chipDragListener = View.OnDragListener { view, dragEvent ->
val draggableItem = dragEvent.localState as Chip
when (dragEvent.action) {
DragEvent.ACTION_DRAG_STARTED -> {
true
}
DragEvent.ACTION_DRAG_ENTERED -> {
true
}
DragEvent.ACTION_DRAG_LOCATION -> {
true
}
DragEvent.ACTION_DRAG_EXITED -> {
//when view exits drop-area without dropping set view visibility to VISIBLE
draggableItem.visibility = View.VISIBLE
view.invalidate()
true
}
DragEvent.ACTION_DROP -> {
//on drop event in the target drop area, read the data and
// re-position the view in it's new location
if (dragEvent.clipDescription.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)) {
val draggedData = dragEvent.clipData.getItemAt(0).text
println("draggedData $draggedData")
}
//on drop event remove the view from parent viewGroup
if (draggableItem.parent != null) {
val parent = draggableItem.parent as ChipGroup
parent.removeView(draggableItem)
}
// get the position to insert at
var pos = -1
for (i in 0 until binding.chipGroup1.childCount) {
val chip = binding.chipGroup1[i] as Chip
val start = chip.x
val end = (chip.x + (chip.width / 2))
if (dragEvent.x in start..end) {
pos = i
break
}
}
//add the view view to a new viewGroup where the view was dropped
if (pos >= 0) {
val dropArea = view as ChipGroup
dropArea.addView(draggableItem, pos)
} else {
val dropArea = view as ChipGroup
dropArea.addView(draggableItem)
}
true
}
DragEvent.ACTION_DRAG_ENDED -> {
draggableItem.visibility = View.VISIBLE
view.invalidate()
true
}
else -> {
false
}
}
}
private fun attachChipDragListener() {
for (i in 0 until binding.chipGroup1.childCount) {
val chip = binding.chipGroup1[i]
if (chip !is Chip)
continue
chip.setOnLongClickListener { view: View ->
// Create a new ClipData.Item with custom text data
val item = ClipData.Item(dragMessage)
// Create a new ClipData using a predefined label, the plain text MIME type, and
// the already-created item. This will create a new ClipDescription object within the
// ClipData, and set its MIME type entry to "text/plain"
val dataToDrag = ClipData(
dragMessage,
arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN),
item
)
// Instantiates the drag shadow builder.
val chipShadow = ChipDragShadowBuilder(view)
// Starts the drag
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
//support pre-Nougat versions
#Suppress("DEPRECATION")
view.startDrag(dataToDrag, chipShadow, view, 0)
} else {
//supports Nougat and beyond
view.startDragAndDrop(dataToDrag, chipShadow, view, 0)
}
view.visibility = View.INVISIBLE
true
}
}
}
}
ChipDragShadowBuilder:
class ChipDragShadowBuilder(view: View) : View.DragShadowBuilder(view) {
//set shadow to be the drawable
private val shadow = ResourcesCompat.getDrawable(
view.context.resources,
R.drawable.shadow_bg,
view.context.theme
)
// Defines a callback that sends the drag shadow dimensions and touch point back to the
// system.
override fun onProvideShadowMetrics(size: Point, touch: Point) {
// Sets the width of the shadow to full width of the original View
val width: Int = view.width
// Sets the height of the shadow to full height of the original View
val height: Int = view.height
// The drag shadow is a Drawable. This sets its dimensions to be the same as the
// Canvas that the system will provide. As a result, the drag shadow will fill the
// Canvas.
shadow?.setBounds(0, 0, width, height)
// Sets the size parameter's width and height values. These get back to the system
// through the size parameter.
size.set(width, height)
// Sets the touch point's position to be in the middle of the drag shadow
touch.set(width / 2, height / 2)
}
// Defines a callback that draws the drag shadow in a Canvas that the system constructs
// from the dimensions passed in onProvideShadowMetrics().
override fun onDrawShadow(canvas: Canvas) {
// Draws the Drawable in the Canvas passed in from the system.
shadow?.draw(canvas)
}
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#color/white"
android:orientation="vertical"
tools:context=".MainActivity">
<com.google.android.material.chip.ChipGroup
android:id="#+id/chipGroup1"
android:layout_width="match_parent"
android:layout_height="56dp"
app:singleSelection="true">
</com.google.android.material.chip.ChipGroup>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#555" />
</LinearLayout>
For understanding how drag works in detail. I would suggest you read: https://www.raywenderlich.com/24508555-android-drag-and-drop-tutorial-moving-views-and-data
So I am new to Kotlin and I am trying to make a super simple app. All it does is when I click Right button it goes right same with left button. The problem is that when I click either button (e.g right button) I can click it until the image goes completely offscreen. So how can I implement a code that once it hits the edge off the screen it stops moving ?
My code
package com.example.change_position_circle
import android.animation.ObjectAnimator
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Button
import kotlinx.android.synthetic.main.activity_main.*
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
//val picture = findViewById<ImageView>(R.id.SpongeBob)
val right_button = findViewById<Button>(R.id.right)
val left_button = findViewById<Button>(R.id.left)
right_button.setOnClickListener()
{
//ObjectAnimator.ofFloat(SpongeBob, "x", 100)
SpongeBob.animate().setDuration(90).translationXBy(100f)
}
left_button.setOnClickListener()
{
//ObjectAnimator.ofFloat(SpongeBob, "translationXBy", 100f).apply {
//duration = 200
// start()
SpongeBob.animate().setDuration(90).translationXBy(-100f)
//}
}
}
}
Thank you for your help
Welcome to Kotlin!
So yeah, you've got your Spongebob animating, now you need some logic to control that animation. The problem here is you don't always want that full animation to happen, right? If he's too close to the edge of the screen, you want the button to only move him as far as that invisible wall (and if he's right against it, that means no movement at all).
The animation and drawing systems don't put any restrictions on where you can put a View, so it's up to you to handle that yourself. You basically need to do this when a button is clicked:
get the Spongebob's position coordinates (it's the X you really care about right now)
work out the position of the edge you care about (the View's coordinates describe where the top left corner is, so if you're looking at the right edge, you need that X coordinate + the width of the View)
work out the X coordinate of the edge of the screen (or parent layout, whatever you want the Spongebob to be contained within)
if the distance between the Spongebob edge and the screen edge is less than your normal animation movement, you need to change it to that remaining distance
you'll also want to work out the appropriate duration too, if he's moving half the usual distance the animation should take half as long
that's a lot to work on, and there are a few ways to do it, but here's one approach just using the screen edges as the bounds
import android.os.Bundle
import android.view.View
import android.widget.Button
import androidx.appcompat.app.AppCompatActivity
import kotlin.math.abs
import kotlin.math.min
private const val MOVE_DISTANCE = 100
private const val MOVE_TIME = 90
class MainActivity : AppCompatActivity() {
private var screenWidth = 0
private lateinit var spongeBob : View
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.spongebob)
// store this when the Activity is created, if the device is rotated the Activity
// will be recreated and this method gets run again
screenWidth = applicationContext.resources.displayMetrics.widthPixels
//val picture = findViewById<ImageView>(R.id.SpongeBob)
val right_button = findViewById<Button>(R.id.right)
val left_button = findViewById<Button>(R.id.left)
spongeBob = findViewById(R.id.spongeBob)
right_button.setOnClickListener()
{
// two possible values - the distance to the edge, and the normal amount we move
// we want the smaller of the two (i.e. always move the normal amount, unless
// the edge is closer than that
val distance = min(distanceToEdge(left = false), MOVE_DISTANCE)
moveSpongeBob(distance)
}
left_button.setOnClickListener()
{
val distance = min(distanceToEdge(left = true), MOVE_DISTANCE)
// we're moving left so we need to use a negative distance
moveSpongeBob (-distance)
}
}
private fun distanceToEdge(left: Boolean): Int {
// Get the Spongebob's top-left position - the call is a method on the View class,
// I'm assuming SpongeBob is a View, and you need to pass an array in because
// that's just how it works for whatever reason...
val location = IntArray(2)
spongeBob.getLocationOnScreen(location)
val x = location[0]
// I'm just using the View.getWidth() call here (Kotlin style) but I don't know
// what the SpongeBob class is, so you'll need to handle this
// You could set this once, like when we get the screen width, but width will be 0 until
// the View is laid out - so you can't do it in #onCreate, #onViewCreated should work
val spongeBobWidth = spongeBob.width
// the left edge is just the x position, however far that is from zero
return if (left) x
// the right edge is the x position plus the width of the bob
else screenWidth - (x + spongeBobWidth)
}
// Actually move the view, by the given distance (negative values to move left)
private fun moveSpongeBob(distance: Int) {
// work out how much this distance relates to our standard move amount, so we can
// adjust the time by the same proportion - converting to float so we don't get
// integer division (where it's rounded to a whole number)
val fraction = distance.toFloat() / MOVE_DISTANCE
// distance can be negative (i.e. moving left) so we need to use the abs function
// to make the duration a postitive number
val duration = abs(MOVE_TIME * fraction).toLong()
spongeBob.animate().setDuration(duration).translationXBy(distance.toFloat())
}
}
There's nicer stuff you can do (and SpongeBob should be called spongeBob and be a View) but that's the basics. This article on the coordinate system might help you out too.
I have this function in my main activity which doesn't display a badge:
private fun setFindShiftBadge(state: HomeState) {
val findShiftsBadge = BadgeDrawable.create(this)
home_framelayout.foreground = findShiftsBadge
findShiftsBadge.badgeGravity = BadgeDrawable.TOP_END
findShiftsBadge.backgroundColor = resources.getColor(R.color.colorWhite)
findShiftsBadge.badgeTextColor = resources.getColor(R.color.colorPrimary)
findShiftsBadge.number = state.availableShifts.size
}
In the same activity, I have this function which displays a badge:
private fun setMyPostedShiftBadge(state: HomeState) {
val userShiftsBadge: BadgeDrawable =
bottom_navigation_view.getOrCreateBadge(R.id.bottom_navigation_my_posted_shifts_text)
userShiftsBadge.number = state.userShifts.size
userShiftsBadge.backgroundColor = resources.getColor(R.color.colorPrimary)
}
Now I understand that the second function works because the badge is set within a BottomNavigationView. Ironically, BottomNavigationView extends FrameLayout. In the Android Documentation:
Add BadgeDrawable as a ViewOverlay to the desired anchor view using attachBadgeDrawable(BadgeDrawable, View, FrameLayout).
Update the BadgeDrawable BadgeDrawable's coordinates (center and bounds) based on its anchor view using updateBadgeCoordinates(View, ViewGroup).
They say to use this code, For API 18+ (APIs supported by ViewOverlay) which I use in the first function that doesn't work:
BadgeDrawable badgeDrawable = BadgeDrawable.create(context);
BadgeUtils.attachBadgeDrawable(badgeDrawable, anchor, null);
I have also tried this solution but it didn't work either. I'm aware there are workarounds such as:
Creating a drawable then setting that onto of the view you want
Placing a TextView within that drawable that you then update with the number you want
Toggle the visibility of the drawable when there are any numbers.
I am on implementation 'com.google.android.material:material:1.2.0-alpha04'
This seems dirty to me since I know that there is a better way to do this. I just can't seem to figure it out. What am I missing? Is this even possible? How have you been able to do this without having to write a workaround? Thanks for your time! Looking forward to learning from your questions and answers!! Have a fantastic day!
BottomNavigationView.getOrCreateBadge() does not only assign the BadgeDrawable as foreground Drawable, it also sets the drawable bounds. Without this step, they stay at (0,0,0,0), so there is nothing to draw.
In order to set the bounds, let's introduce an extension function for BadgeDrawable
/**
* Inspired by BadgeUtils in com.google.android.material library
*
* Sets the bounds of a BadgeDrawable
*/
private fun BadgeDrawable.setBoundsFor(#NonNull anchor: View, #NonNull parent: FrameLayout){
val rect = Rect()
parent.getDrawingRect(rect)
this.setBounds(rect)
this.updateBadgeCoordinates(anchor, parent)
}
Use this function with your BadgeDrawable for FrameLayout:
private fun setFindShiftBadge(state: HomeState) {
val findShiftsBadge = BadgeDrawable.create(this)
// configure the badge drawable
findShiftsBadge.badgeGravity = BadgeDrawable.TOP_END
findShiftsBadge.backgroundColor = resources.getColor(R.color.colorWhite)
findShiftsBadge.badgeTextColor = resources.getColor(R.color.colorPrimary)
findShiftsBadge.number = state.availableShifts.size
// set bounds inside which it will be drawn
findShiftsBadge.setBoundsFor(btn_badge, home_framelayout)
// assign as foreground drawable
home_framelayout.foreground = findShiftsBadge
}
BadgeDrawable badgeDrawable = BadgeDrawable.create(this);
badgeDrawable.setNumber(15);
FrameLayout frameLayout = findViewById(R.id.framelayout);
ImageView imageView = findViewById(R.id.iv_center);
//core code
frameLayout.setForeground(badgeDrawable);
frameLayout.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
//either of the following two lines of code work
//badgeDrawable.updateBadgeCoordinates(imageView, frameLayout);
BadgeUtils.attachBadgeDrawable(badgeDrawable, imageView, frameLayout);
});
<FrameLayout
android:id="#+id/framelayout"
android:layout_width="150dp"
android:layout_height="150dp">
<ImageView
android:layout_gravity="center"
android:id="#+id/iv_center"
android:layout_width="50dp"
android:layout_height="50dp"
android:scaleType="fitXY"
android:src="#drawable/ic_beard" />
</FrameLayout>
I'm currently working on animation that will grow up the view if the user clicks it. Basically, its a card that, when clicked, it will reveal the bottom content. For that, I'm extending Animation like this:
Val collapseAnimation = object : Animation() {
override fun applyTransformation(interpolatedTime: Float, t: Transformation?) {
val interpolatedInverted = 1 - interpolatedTime
val headerLp = headerImage.layoutParams
headerLp.width = ...
headerImage.layoutParams = headerLp
}
}
The problem is that i need to get the height of a view (wrap_content) that is defined in XML as 0dp. Basically, I want to grow up a view from 0dp to wrap_content and for that i need to know what is the wrap_content size.
How can I accomplish that in the most efficient way, without hard coding the view size?
In order to measure a view with different layout params and get its height, we can do the following:
contentContainer.measure(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
val contentContainerFinalHeight = contentContainer.measuredHeight