I need to optimize my Android app to look good on phones with a notch, like the Essential Phone.
This phones have different status bar heights than the standard 25dp value, so you can't hardcode that value.
The Android P developer preview released yesterday includes support for the notch and some APIs to query its location and bounds, but for my app I only need to be able to get the status bar height from XML, and not use a fixed value.
Unfortunately, I couldn't find any way to get that value from XML.
Is there any way?
Thank you.
I have already found the answer:
The recommended way is to use the WindowInsets API, like the following:
view.setOnApplyWindowInsetsListener { v, insets ->
view.height = insets.systemWindowInsetTop // status bar height
insets
}
Accessing the status_bar_height, as it's a private resource and thus subject to change in future versions of Android, is highly discouraged.
It is not a solution, but it can explain you how it should be don in correct way and how things are going under the hood:
https://www.youtube.com/watch?v=_mGDMVRO3iE
OLD SOLUTION:
Here is small trick:
#*android:dimen/status_bar_height
android studio will show you an error, but it will work like a charm :)
Method provides the height of status bar
public int getStatusBarHeight() {
int result = 0;
int resourceId = getResources().getIdentifier("status_bar_height", "dimen", "android");
if (resourceId > 0) {
result = getResources().getDimensionPixelSize(resourceId);
}
return result;
}
class StatusBarSpacer #JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
View(context, attrs) {
private var statusHeight: Int = 60
init {
if (context is Activity && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
context.window.decorView.setOnApplyWindowInsetsListener { _, insets ->
statusHeight = insets.systemWindowInsetTop
requestLayout()
insets
}
context.window.decorView.requestApplyInsets()
} else statusHeight = resources.getDimensionPixelSize(
resources.getIdentifier("status_bar_height", "dimen", "android")
)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) =
setMeasuredDimension(0, statusHeight)
}
I have found a solution to work with status bar size that should be fit to any device. It is a way to transform the size from code to XML layouts.
The solution is to create a view that is auto-sized with the status bar size. It is like a guide from constraint layout.
I am sure it should be a better method but I didn't find it. And if you consider something to improve this code please let me now:
KOTLIN
class StatusBarSizeView: View {
companion object {
// status bar saved size
var heightSize: Int = 0
}
constructor(context: Context):
super(context) {
this.init()
}
constructor(context: Context, attrs: AttributeSet?):
super(context, attrs) {
this.init()
}
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int):
super(context, attrs, defStyleAttr) {
this.init()
}
private fun init() {
// do nothing if we already have the size
if (heightSize != 0) {
return
}
// listen to get the height
(context as? Activity)?.window?.decorView?.setOnApplyWindowInsetsListener { _, windowInsets ->
// get the size
heightSize = windowInsets.systemWindowInsetTop
// return insets
windowInsets
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// if height is not zero height is ok
if (h != 0 || heightSize == 0) {
return
}
// apply the size
postDelayed(Runnable {
applyHeight(heightSize)
}, 0)
}
private fun applyHeight(height: Int) {
// apply the status bar height to the height of the view
val lp = this.layoutParams
lp.height = height
this.layoutParams = lp
}
}
Then you can use it in XML as a guide:
<com.foo.StatusBarSizeView
android:id="#+id/fooBarSizeView"
android:layout_width="match_parent"
android:layout_height="0dp" />
The "heightSize" variable is public to can use it in the code if some view need it.
Maybe it helps to someone else.
Related
I have a custom view that extends LinearLayout and implements onMeasure. I'd like the children to be either as wide as they need to be or filling the available space.
XML files:
Parent:
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.example.myapplication.AtMostLinearLayout
android:id="#+id/at_most_linear_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</FrameLayout>
Button example:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:src="#android:drawable/ic_delete" />
</FrameLayout>
Views are added programmatically, for example:
findViewById<AtMostLinearLayout>(R.id.at_most_linear_layout).apply {
repeat(4) {
LayoutInflater.from(context).inflate(R.layout.button, this)
}
}
Finally the Custom view class:
class AtMostLinearLayout #JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : LinearLayout(context, attrs, defStyle) {
private val maxTotalWidth = context.resources.getDimensionPixelOffset(R.dimen.max_buttons_width)
init {
orientation = HORIZONTAL
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
if (childCount < 1) return
val newWidth = min(measuredWidth, maxTotalWidth)
var availableWidth = newWidth
var numberOfLargeChildren = 0
repeat(childCount) {
getChildAt(it).let { child ->
if (child.measuredWidth > availableWidth / childCount) {
availableWidth -= child.measuredWidth
numberOfLargeChildren++
}
}
}
val minChildWidth = availableWidth / max(childCount - numberOfLargeChildren, 1)
repeat(childCount) {
getChildAt(it).apply {
measure(
MeasureSpec.makeMeasureSpec(max(measuredWidth, minChildWidth), EXACTLY),
UNSPECIFIED
)
}
}
setMeasuredDimension(
makeMeasureSpec(newWidth, EXACTLY), makeMeasureSpec(measuredHeight, EXACTLY))
}
}
It works fine in LTR:
In RTL however the views are off set for some reason and are drawn outside the ViewGroup:
Where could this offset coming from? It looks like the children's measure calls are being added to the part, or at least half of it...
You could use the Layout Inspector (or "show layout boundaries" on device), in order to determine why it behaves as it does. The calculation of the horizontal offset may have to be flipped; by substracting instead of adding ...in order to account for the change in layout direction, where the absolute offset in pixels may always be understood as LTR.
If the canvas is rtl in the onDraw method, have you tried inverting it?
You could try using View.getLayoutDirection(). Return the layout direction.
onDraw method override and
val isRtl = layoutDirection == View.LAYOUT_DIRECTION_RTL
if(isRtrl){
canvas.scale(-1f, 1f, width / 2, height / 2)
}
After reading through the LinearLayout & View measure and layout code some more I figured out why this it's happening.
Whenever LinearLayout#measure is called mTotalLength is calculated, which represents the calculated width of the entire view. As I'm manually remeasuring the children with a different MeasureSpec LinearLayout cannot cache these values. Later in the layout pass the views use the cached mTotalLength to set the child's left i.e. the offset. The left is based on the gravity of the child and thus being affected by the cached value.
See: LinearLayout#onlayout
final int layoutDirection = getLayoutDirection();
switch (Gravity.getAbsoluteGravity(majorGravity, layoutDirection)) {
case Gravity.RIGHT:
// mTotalLength contains the padding already
childLeft = mPaddingLeft + right - left - mTotalLength;
break;
case Gravity.CENTER_HORIZONTAL:
// mTotalLength contains the padding already
childLeft = mPaddingLeft + (right - left - mTotalLength) / 2;
break;
case Gravity.LEFT:
default:
childLeft = mPaddingLeft;
break;
}
I've change the impl to ensure it always sets the gravity to Gravity.LEFT. I should probably manually implement onLayout instead!
class AtMostLinearLayout #JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : LinearLayout(context, attrs, defStyle) {
private val maxTotalWidth = context.resources.getDimensionPixelOffset(R.dimen.max_buttons_width)
init {
orientation = HORIZONTAL
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
if (childCount < 1) return
val newWidth = min(measuredWidth, maxTotalWidth)
var availableWidth = newWidth
var numberOfLargeChildren = 0
repeat(childCount) {
getChildAt(it).let { child ->
if (child.measuredWidth > availableWidth / childCount) {
availableWidth -= child.measuredWidth
numberOfLargeChildren++
}
}
}
val minChildWidth = availableWidth / max(childCount - numberOfLargeChildren, 1)
repeat(childCount) {
getChildAt(it).let { child ->
child.measure(
makeMeasureSpec(max(child.measuredWidth, minChildWidth), EXACTLY),
UNSPECIFIED
)
}
}
// Effectively always set it to Gravity.LEFT to prevent LinearLayout using its
// internally-cached mTotalLength to set the Child's left.
gravity = if (layoutDirection == View.LAYOUT_DIRECTION_RTL) Gravity.END else Gravity.START
setMeasuredDimension(
makeMeasureSpec(newWidth, EXACTLY), makeMeasureSpec(measuredHeight, EXACTLY))
}
}
I don't understand why you need a custom ViewGroup for this work. How about set layout_weight when you add child view to LinearLayout.
Just simple by:
val layout = findViewById<LinearLayout>(R.id.linear_layout)
repeat(4) {
val view = LayoutInflater.from(this).inflate(R.layout.button, layout, false)
view.apply {
updateLayoutParams<LinearLayout.LayoutParams> {
width = 0
weight = 1f
}
}
layout.addView(view)
}
I've created a custom view that should be a button, but i can't quite get it to work.
So my custom view is extended from View, and I need to to make a fragment navigation when clicked
I've tried to use the override fun performClick() and in it do a rootView.findNavController().navigate(R.id.action_menu_to_settings) but it crashes. I also tried use the .setOnClickLister() { // navigation } but it also doesn't work.
Can anyone tell me how set a clickListener on a custom view for a navigation? Thx
If you are creating a custom view, the best way to handle the click operations is like this:
class MyView #JvmOverloads constructor (
context: Context,
attrs: AttributeSet? = null,
defStyleRes: Int = 0,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleRes, defStyleAttr) {
var touchType = -1 // No user touch yet.
var onClickListener: () -> Unit = {
Log.d(TAG, "on click not yet implemented")
}
override fun onDraw(canvas: Canvas?) {
/* your code here */
}
override fun onTouchEvent(e: MotionEvent?): Boolean {
val value = super.onTouchEvent(e)
when(e?.action) {
MotionEvent.ACTION_DOWN -> {
/* Determine where the user has touched on the screen. */
touchType = 1 // for eg.
return true
}
MotionEvent.ACTION_UP -> {
/* Now that user has lifted his finger. . . */
when (touchType) {
1 -> onClickListener()
}
}
}
return value
}
}
And in your client class (Activity/Fragment), with the instance of the specific custom view that you instantiated, apply the following:
myView.onClickListener = {
findNavController().navigate(R.id.action_menu_to_settings)
}
I am attempting to create a custom, vertical SeekBar that will be used to scroll text within a ScrollView. Here is that SeekBar:
class CustomSeekBar : SeekBar {
constructor(context: Context) : super(context) {}
constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) {}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(h, w, oldh, oldw)
}
#Synchronized
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(heightMeasureSpec, widthMeasureSpec)
setMeasuredDimension(measuredHeight, measuredWidth)
}
override fun onDraw(c: Canvas) {
c.rotate(90f)
c.translate(0f, -width.toFloat())
super.onDraw(c)
}
override fun onTouchEvent(event: MotionEvent): Boolean {
super.onTouchEvent(event)
if (!isEnabled) {
return false
}
when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_UP -> {
val i = (max - (max * event.y / height)).toInt()
progress = 100 - i
onSizeChanged(width, height, 0, 0)
}
MotionEvent.ACTION_CANCEL -> {
}
}
return true
}
}
Here is my code that attempts to attach the ScrollView to the SeekBar in the onStart of my Fragment:
override fun onStart() {
super.onStart()
val helpScrollView = view!!.findViewById<ScrollView>(R.id.helpScrollView)
val helpSeekBar = view!!.findViewById<CustomSeekBar>(R.id.helpSeekBar)
helpScrollView.viewTreeObserver.addOnGlobalLayoutListener {
val scroll: Int = getScrollRange(helpScrollView)
helpSeekBar.max = scroll
}
val seekBarListener = object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
val min = 0
if(progress < min) {
seekBar?.progress = min;}
helpScrollView.scrollTo(0, progress)
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
}
override fun onStopTrackingTouch(seekBar: SeekBar?) {
}
}
helpSeekBar.setOnSeekBarChangeListener(seekBarListener)
}
private fun getScrollRange(scrollView: ScrollView): Int {
var scrollRange = 0
if (scrollView.childCount > 0) {
val child = scrollView.getChildAt(0)
scrollRange =
Math.max(0, child.height - (scrollView.height - scrollView.paddingBottom - scrollView.paddingTop))
}
return scrollRange
}
The SeekBar on seems to only react to touches on its lower half, and the thumbs shadow still scrolls horizontally off the screen. The ScrollView moves slightly, but only in one direction at the beginning of the scroll. What am I doing wrong?
Check updated answer at the bottom for a solution
Who are we to judge what you need to do, client is almost always right!
Anyway, if you still wanted to do this, (it may or may not work) here's what I think the architecture should try to look like:
The SeekBar is stupid, shouldn't know ANYTHING about what it is doing. You give it a min (0?) and a MAX (n). You subscribe to its listener and receive the "progress" updates. (ha, that's the easy part)
ScrollView containing the text, is also very unintelligent beyond its basics, it can be told to scroll to a position (this will involve some work I guess, but I'm sure it's not difficult).
Calculating the MAX value is going to be what determines how hard step 2 will be. If you use each "step" in your scrolling content as a "Line of text", then this is fine, but you'd need to account for layout changes and re-measures that may change the size of the text. Shouldn't be "crazy" but, keep it in mind or you will receive your first bug report as soon as a user rotates the phone :)
If the text is dynamic (can change real-time) your function for step 3 should be as efficient as possible, you want those numbers "asap".
Responding to progress changes from the seekbar and having your scrollable content scroll is going to be either super easy (you found a way to do it very easily) or complicated if you cannot make the ScrollView behave as you want.
Alternative Approach
If your text is really long, I bet you're going to have better performance if you split your text in lines and use a recyclerview to display it, which would then make scrolling "slightly easier" as you could move your recyclerview to a position (line!).
UPDATE
Ok, so out of curiosity, I tried this. I launched AS, created a new project with an "Empty Activity" and added a ScrollView with a TextView inside, and a SeekBar at the bottom (horizontal).
Here's the Gist with all the relevant bits:
https://gist.github.com/Gryzor/5a68e4d247f4db1e0d1d77c40576af33
At its core the solution works out of the box:
scrollView.scrollTo(0, progress) where progress is returned to you by the framework callback in the seekBar's onProgressChanged().
Now the key is to set the correct "Max" to the seekbar.
This is obtained by dodging a private method in the ScrollView with this:
(credit where is due, I got this idea from here)
private fun getScrollRange(scrollView: ScrollView): Int {
var scrollRange = 0
if (scrollView.childCount > 0) {
val child = scrollView.getChildAt(0)
scrollRange =
Math.max(0, child.height - (scrollView.height - scrollView.paddingBottom - scrollView.paddingTop))
}
return scrollRange
}
This works fine, assuming you wait for the seekBar to measure/layout (hence why I had to do this in the treeObserver).
Background
Sometimes, all items of the recyclerView are already visible to the user.
In this case, it wouldn't matter to the user to see overscroll effect, because it's impossible to actually scroll and see more items.
The problem
I know that in order to disable overscroll effect on RecyclerView, I can just use:
recyclerView.setOverScrollMode(View.OVER_SCROLL_NEVER);
but I can't find out when to trigger this, when the scrolling isn't possible anyway.
The question
How can I Identify that all items are fully visible and that the user can't really scroll ?
If it helps to assume anything, I always use LinearLayoutManager (vertical and horizontal) for the RecyclerView.
<android.support.v7.widget.RecyclerView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:overScrollMode="never"/>
Just add android:overScrollMode="never" in XML
you could give OVER_SCROLL_IF_CONTENT_SCROLLS a try. Accordingly to the documentation
Allow a user to over-scroll this view only if the content is large
enough to meaningfully scroll, provided it is a view that can scroll.
Or you could check if you have enough items to trigger the scroll and enable/disable the over scroll mode, depending on it. Eg
boolean notAllVisible = layoutManager.findLastCompletelyVisibleItemPosition() < adapter.getItemCount() - 1;
if (notAllVisible) {
recyclerView.setOverScrollMode(allVisible ? View.OVER_SCROLL_NEVER);
}
Since android:overScrollMode="ifContentScrolls" is not working for RecyclerView(see https://issuetracker.google.com/issues/37076456) I found some kind of a workaround which want to share with you:
class MyRecyclerView #JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
super.onLayout(changed, l, t, r, b)
val canScrollVertical = computeVerticalScrollRange() > height
overScrollMode = if (canScrollVertical) OVER_SCROLL_ALWAYS else OVER_SCROLL_NEVER
}
}
You could try something like this:
totalItemCount = linearLayoutManager.getItemCount();
firstVisibleItem = linearLayoutManager.findFirstCompletelyVisibleItemPosition()
lastVisibleItem = linearLayoutManager.findLastCompletelyVisibleItemPosition();
if(firstVisibleItem == 0 && lastVisibleItem -1 == totalItemCount){
// trigger the overscroll effect
}
Which you could add in the onScrolled() of an OnScrollListener that you add on your RecyclerView.
Longer workaround, based on here (to solve this issue), handles more cases, but still a workaround:
/**a temporary workaround to make RecyclerView handle android:overScrollMode="ifContentScrolls" */
class NoOverScrollWhenNotNeededRecyclerView : RecyclerView {
private var enableOverflowModeOverriding: Boolean? = null
private var isOverFlowModeChangingAccordingToSize = false
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
override fun setOverScrollMode(overScrollMode: Int) {
if (!isOverFlowModeChangingAccordingToSize)
enableOverflowModeOverriding = overScrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS
else isOverFlowModeChangingAccordingToSize = false
super.setOverScrollMode(overScrollMode)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
super.onLayout(changed, l, t, r, b)
if (enableOverflowModeOverriding == null)
enableOverflowModeOverriding = overScrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS
if (enableOverflowModeOverriding == true) {
val canScrollVertical = computeVerticalScrollRange() > height
val canScrollHorizontally = computeHorizontalScrollRange() > width
isOverFlowModeChangingAccordingToSize = true
overScrollMode = if (canScrollVertical || canScrollHorizontally) OVER_SCROLL_ALWAYS else OVER_SCROLL_NEVER
}
}
}
Write custom RecyclerView.OnScrollListener() for working with overScrollMode
/**
* Workaround because [View.OVER_SCROLL_IF_CONTENT_SCROLLS] not working properly.
*
* [showHeader]/[showFooter] - for customization, if need show only specific scroll edge.
*/
class RecyclerOverScrollListener(
private val showHeader: Boolean = true,
private val showFooter: Boolean = true
) : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val lm = recyclerView.layoutManager as? LinearLayoutManager ?: return
val isFirstVisible = lm.findFirstCompletelyVisibleItemPosition() == 0
val isLastVisible = lm.findLastCompletelyVisibleItemPosition() == lm.itemCount - 1
val allVisible = isFirstVisible && isLastVisible
recyclerView.overScrollMode = if (allVisible) {
View.OVER_SCROLL_NEVER
} else {
val showHeaderEdge = showHeader && isFirstVisible && !isLastVisible
val showFooterEdge = showFooter && !isFirstVisible && isLastVisible
if (showHeader && showFooter || showHeaderEdge || showFooterEdge) {
View.OVER_SCROLL_ALWAYS
} else {
View.OVER_SCROLL_NEVER
}
}
}
}
How implement for RecyclerView:
recyclerView.addOnScrollListener(RecyclerOverScrollListener(showFooter = false))
Also don't forget about specify edge color inside styles:
<item name="android:colorEdgeEffect">#color/yourRippleColor</item>
Is there anyway to know when a progressbar has reached it maximum. Like a Listener then could plug into the ProgressBar and lets us know that the progress bar is at the maximum level ?
Kind Regards
There isn't a direct way to do this. A workaround could be to make a custom implementation of the ProgressBar and override the setProgress method:
public MyProgressBar extends ProgressBar
{
#Override
public void setProgress(int progress)
{
super.setProgress(progress);
if(progress == this.getMax())
{
//Do stuff when progress is max
}
}
}
I think the cleanest way would be just adding this to your code:
if (progressBar.getProgress() == progressBar.getMax()) {
// do stuff you need
}
If you need onProgressChanged like SeekBar - create custom progress (Kotlin):
class MyProgressBar : ProgressBar {
private var listener: OnMyProgressBarChangeListener? = null
fun setOnMyProgressBarChangeListener(l: OnMyProgressBarChangeListener) {
listener = l
}
constructor(context: Context?) : super(context)
constructor(context: Context?, attrs: AttributeSet?) : super(
context,
attrs
)
constructor(
context: Context?,
attrs: AttributeSet?,
defStyleAttr: Int
) : super(context, attrs, defStyleAttr)
override fun setProgress(progress: Int) {
super.setProgress(progress)
listener?.onProgressChanged(this)
}
interface OnMyProgressBarChangeListener {
fun onProgressChanged(myProgressBar: MyProgressBar?)
}
}
And for example in your fragment:
progress_bar?.setOnMyProgressBarChangeListener(object :
MyProgressBar.OnMyProgressBarChangeListener {
override fun onProgressChanged(myProgressBar: MyProgressBar?) {
val p = progress_bar.progress
// do stuff like this
if (p != 100) {
percentCallback?.changePercent(p.toString()) // show animation custom view with grow percents
} else {
shieldView.setFinishAndDrawCheckMark() // draw check mark
}
}
})
I think overall you should never have to do this. Is there just one valid case where you need to listen to a progressbar progress? I mean, usually it's the other way around: you set the progressbar progress based on something, not the other way around, so you need to track the progress of that something instead of listening to a view (which may or may not exist by the way).