I have an Activity with a RecyclerView in a data binding layout. RecyclerView takes up the whole screen, and looking at making the UX go full screen, drawn under the status and nav bars.
I'm calling setSystemUiVisibility in activity's onCreate as below.
window.decorView.setSystemUiVisibility(
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
)
Now the RecyclerView is drawn under the system bars, so I want to make sure it has enough padding so the items don't overlap with the system UI.
I found 2 ways of doing this, via a BindingAdapter.
Option 1
var statusBar = 0
var resourceId = view.resources.getIdentifier("status_bar_height", "dimen", "android")
if (resourceId > 0) {
statusBar = view.resources.getDimensionPixelSize(resourceId)
}
var navBar = 0
resourceId = view.resources.getIdentifier("navigation_bar_height", "dimen", "android")
if (resourceId > 0) {
navBar = view.resources.getDimensionPixelSize(resourceId)
}
view.setPadding(0, statusBar, 0, navBar)
Option 2
var insets = view.rootWindowInsets.stableInsets
view.setPadding(0, insets.top, 0, insets.bottom)
I prefer the first, because it (with limited testing on emulators seems to) work on API 21, 28 and 29.
Option 2 only works on API 29, and also seems to get null on view.rootWindowInsets if/when the view is not attached. (So I guess I have to add a listener and wait for it to be attached before doing this)
So my question is, is there a down side to Option 1? Can I use it over the new API in 29? Is there any scenarios that Option 1 would not work?
(I think Option 1 might not work well on tablets where both nav and systems bars are on the bottom, so extra padding will be applied to the wrong side.)
A little bit late to the party, but here is the way that I've been doing, someone might need it.
For Android M and above, you can call View#rootWindowInsets directly, otherwise rely on Java's Reflection to access the private field mStableInsets
fun getStableInsets(view: View): Rect {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val windowInsets = view.rootWindowInsets
if (windowInsets != null) {
Rect(windowInsets.stableInsetLeft, windowInsets.stableInsetTop,
windowInsets.stableInsetRight, windowInsets.stableInsetBottom)
} else {
// TODO: Edge case, you might want to return a default value here
Rect(defaultInsetLeft, defaultInsetTop, defaultInsetRight, defaultInsetBottom)
}
} else {
val attachInfoField = View::class.java.getDeclaredField("mAttachInfo")
attachInfoField.isAccessible = true
val attachInfo = attachInfoField.get(view);
if (attachInfo != null) {
val stableInsetsField = attachInfo.javaClass.getDeclaredField("mStableInsets")
stableInsetsField.isAccessible = true
Rect(stableInsetsField.get(attachInfo) as Rect)
} else {
// TODO: Edge case, you might want to return a default value here
Rect(defaultInsetLeft, defaultInsetTop, defaultInsetRight, defaultInsetBottom)
}
}
}
Update:
stableInsetBottom .etc. are now deprecated with message
Use {#link #getInsetsIgnoringVisibility(int)} with {#link Type#systemBars()}
* instead.
Unfortunately systemBars() was graylisted in API 29 and is blacklisted in API 30 plus using this seems to work on API 30 emulator, however (some) real devices even running API 29 throws.
Below is logcat from Galaxy S20 FE
Accessing hidden method Landroid/view/WindowInsets$Type;->systemBars()I (blacklist, linking, denied)
2021-01-17 01:45:18.348 23013-23013/? E/AndroidRuntime: FATAL EXCEPTION: main
Process: test.app.package, PID: 23013
java.lang.NoSuchMethodError: No static method systemBars()I in class Landroid/view/WindowInsets$Type; or its super classes (declaration of 'android.view.WindowInsets$Type' appears in /system/framework/framework.jar!classes3.dex)
No answer for this it seems. Please put an answer if you find anything not covered below.
Using Option 1 I noticed on devices that do OEM specific gesture navigation atleast, when those gesture modes are active, above will still return full navigation bar height even though no visible navigation bar is present. So above will still pad the UI when it shouldn't.
Option 2 keeps returning null for insets until the view is attached so if you're doing this on a BindingAdapter, it won't work. It needs to be called after the view is attached.
My current solution is as below.
if( Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
view.doOnAttach {
var bottom = it.rootWindowInsets?.stableInsetBottom?: 0
var top = it.rootWindowInsets?.stableInsetTop?: 0
view.setPadding(0, top, 0, bottom)
}
}
else {
// use option1, old devices don't have custom OEM specific gesture navigation.
// or.. just don't support versions below Android M ¯\_(ツ)_/¯
}
Caveats
Some OEMs (well atleast OnePlus) decided not to restart some activities, especially ones that are paused, when the navigation mode changed. So if the user decides to switch away from your app, change the navigation mode and return, your app may still overlap navigation bar until the activity is restarted.
Related
I want to show a badge on a toolbar action. The badge number is updated by a LiveData value.
This is how I attach the badge:
BadgeUtils.attachBadgeDrawable(inboxBadgeDrawable, toolbar, R.id.menu_inbox);
I tried different places for that call, including Activity.onCreateOptionsMenu(), Activity.onPrepareOptionsMenu() and androidx.lifgecycle.Observer.onChanged().
When anything changes (toolbar or badge content), the badge is misplaced, traveling down left. Or it is duplicated to another action.
I guess attachBadgeDrawable tries to find the container view of R.id.menu_inbox inside the toolbar, inserts the badge and updates it's offsets. If the container view of the menu item changes, the old container view still has the old badge and there is no (sensible) way to remove it. Also, application of the offsets seems to stack.
So, is there any other intended way of using the BadgeDrawable on a toolbar action icon?
I understand that this feature is still experimental. Will this issue be addressed and if yes, how long will it approximately take? (I use com.google.android.material:material:1.3.0-beta01 right now.)
This question is mainly addressed to the developers of the component because usage questions should be asked here according to https://github.com/material-components/material-components-android.
EDIT: I also created an issue (feature request) on the project's tracker: https://github.com/material-components/material-components-android/issues/1967
I'm not sure it is an official solution but this is still a workaround. I ended up with detaching the BadgeDrawable on every onPrepareOptionsMenu, in case the menu items were changed or rearranged
// This is an indicator of whether we need to show the badge or not
private var isFilterOn: Boolean = false
private var filterBadge: BadgeDrawable? = null
#SuppressLint("UnsafeExperimentalUsageError")
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
val filterItem = menu.findItem(R.id.action_filter)
val toolbar = requireActivity().findViewById<Toolbar>(R.id.toolbar)
if(filterBadge != null) {
BadgeUtils.detachBadgeDrawable(filterBadge!!, toolbar, R.id.action_filter)
filterBadge = null
}
if(isFilterOn) {
filterBadge = BadgeDrawable.create(requireContext()).also {
BadgeUtils.attachBadgeDrawable(it, toolbar, R.id.action_filter)
}
}
}
I have a view where a user enters a goal, a date to complete it by, and a confirmation checkbox. A user must enter all of this to move forwards. Thus, I have checks to see if any of the fields are empty. Here is the code for that. This code works.
private fun toggleInputRequiredError(
isErrorVisible: Boolean,
view: TextInputLayout,
errorText: String
) {
when (isErrorVisible) {
true -> {
view.error = errorText
}
else -> view.error = null
}
}
toggleInputRequiredError(
!state.isGoalChecked,
goalAffirmationCheckBoxErrorContainer,
getString(R.string.required_field)
).run {
if (!state.isGoalChecked) {
goalAffirmationCheckBox.isFocusable = true
goalAffirmationCheckBox.requestFocus()
goalAffirmationCheckBox.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED)
}
}
toggleInputRequiredError(
!state.isValidDateInput,
goalDateInputLayout,
getString(R.string.required_field)
).run {
if (!state.isValidDateInput) {
goalCompletionDateInput.isFocusable = true
goalCompletionDateInput.requestFocus()
goalCompletionDateInput.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED)
}
}
toggleInputRequiredError(
!state.isValidDescriptionInput,
goalDescriptionInputLayout,
getString(R.string.required_field)
).run {
if( !state.isValidDescriptionInput) {
goalDescriptionInput.isFocusable = true
goalDescriptionInput.requestFocus()
goalDescriptionInput.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED)
}
}
Now I have to enable accessibility for this view with also works, on most phones except for the latest Samsung phones. The desired behaviour are exemplified by the Pixel XL but also the Samsung S8. Here is an image to show this
On the newer Samsungs the sendAccessibilityEvent doesn't seem to actually focus on the view that needs to be addressed. Here is an image to show this behavior on the Samsung S10+ and Samsung Note 9.
I set the content description of these views in the XML. I noticed that the newer Samsung phones will read the "Required" text on the screen but not focus on it. This means that it ignores the content description in the XML. The last thing is that the view that needs to focusing seems not to experience the sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED) event
Let me know if you have any thoughts on how to address this or if you have any suggestions I can try
In the Samsung S10 device, I can't able set accessibility focus to the custom view. Then I have achieved based on the following code so can try this with your follow maybe it would be helpful.
val task = Runnable {
val mA11yManager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
if (mA11yManager.isEnabled) {
// Equivalent to ACTION_ACCESSIBILITY_FOCUS
plus_chip_text?.performAccessibilityAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null);
// we fire selection events here not in View
plus_chip_text?.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED)
}
}
val worker: ScheduledExecutorService = newSingleThreadScheduledExecutor()
worker.schedule(task, 1, TimeUnit.SECONDS)
Sorry I know this is almost a year later, but we found a very similar issue on Samsung Talkback running Android 10. It would work fine on earlier versions of Android.
We found creating the following kotlin extension function seemed to work for all versions of Android, as well as working well for both Samsung Voice Assistant and Talkback.
Hope this is able to help anyone else facing a similar issue.
fun View?.requestAccessibilityFocus(): View? {
this?.performAccessibilityAction(ACTION_ACCESSIBILITY_FOCUS, null)
this?.sendAccessibilityEvent(TYPE_VIEW_SELECTED)
return this
}
I have a problem I have not yet been able to understand, nor solve.
On my android project, I have a BaseActivity. There, among other functions, I decided to add a function to show or hide a loading view when necessary. It works as intended, but sometimes an error happens.
I will try to raise some important info about my project I think can be useful. My app is integrated with an external login app. I call it when the services I call need to refresh it's token. When the user logs in on the app, it calls a listener and give me back control on mine.
The problem is the next one:
I come to an activity that needs to call a service and I have the token all right, but then I lock the phone. After a long period of time, I unlock the phone and, from the same activity, I call again the service. My activity shows de loader as intended and, as my token is expired, I call the login app from it's SDK.
When I come back to my app, and I call the service I wanted successfully, the app tries to hide the loader. This is where the fail comes, as I can't change the visibility to GONE. I looked for it on the view hierarchy and find it, but with visibility = VISIBLE.
Here is the piece of code from the loader, hope someone can find where I'm making the mistake!
abstract class BaseActivity : DaggerAppCompatActivity(){
// These are the IDS of the Views I'm adding to the activity, so I can track them and change their visibility
var imgLoadingID = -1
var rvLoadingID = -1
fun showLoading() {
// If the views are added I show them
if (imgLoadingID > 0 && rvLoadingID > 0) {
val imageView = findViewById<ImageView>(imgLoadingID)
val relativeLayout = findViewById<RelativeLayout>(rvLoadingID)
relativeLayout.visibility = View.VISIBLE
imageView.visibility = VISIBLE
imageView.isClickable = false
imageView.isFocusable = false
} else {
// else I create them and show them
val imgLoading = ImageView(this)
imgLoading.id = View.generateViewId()
imgLoadingID = imgLoading.id
val maxpx = CustomUtils.ViewUtils.converIntToDps(65, this)
Glide.with(this).asGif().load(R.mipmap.loading).into(imgLoading)
val relativeLayout = RelativeLayout(this)
relativeLayout.id = View.generateViewId()
rvLoadingID = relativeLayout.id
var params = RelativeLayout.LayoutParams(maxpx, WRAP_CONTENT)
params.addRule(RelativeLayout.CENTER_IN_PARENT)
relativeLayout.addView(imgLoading, params)
relativeLayout.background = getDrawable(R.color.pure_white_97)
relativeLayout.isClickable = true
relativeLayout.isFocusable = true
findViewById<ViewGroup>(android.R.id.content).addView(relativeLayout, RelativeLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT))
imgLoading.visibility = VISIBLE
}
// I lock the back button so people don't cancel my requests
esBackPressedBlocked = true
}
// Here I find the views and change their visibility.
fun hideLoading(){
if(imgLoadingID > 0 && rvLoadingID > 0) {
val imageView = findViewById<ImageView>(imgLoadingID)
val relativeLayout = findViewById<RelativeLayout>(rvLoadingID)
relativeLayout.visibility = View.GONE
imageView.visibility = View.GONE
}
esBackPressedBlocked = false
}
}
I deleted some logs I added to the whole function, but when it fails, it enters on the hideLoading() function, even on the relativeLayout.visibility = View.GONE part.
The function used to be with the Views as an whole object instead of their ids, but I found it more reliable this way, and saving the views instead of their Id's had the same problem.
My main concern is how Android manages my application while the phone is locked for this period of time (the fails happened after 8-10 hours of inactivity). I think something there can be creating this issue. I thought also about the external Login app, since it's sdk is launching their app's intent and calling me from a listener when coming back, but, since my code is being executed, I think Android it's managing my views on an strange way. Or maybe I try to hide the loading view before I'm on the resumed activity... I don't really know.
BTW, I know there are easier solutions on showing a loader, but I wanted to create it the cleanest way. If you have any cleaner approach I'm open to any solution.
If there is anything unclear let me know in the comments, and I hope my English was clear enough to express myself, it's a tricky problem that I can't understand, so it's difficult for me to explain it.
Thanks!!
For iOS I am using app.ios.window.safeAreaInsets to discover the safe insets in NativeScript.
Similarly, I would like to discover the safe insets for Android phones, so I can properly handle Android devices that have a soft navigation for example or newer Android phones that have a notch as well (for example the OnePlus 6).
I see in the tns-platform-declarations that for SDK levels 20+ there is the class android.view.WindowInsets which has methods like getSystemWindowInsetTop() and getSystemWindowInsetBottom(), which seems to be exactly what I need.
I am struggling however to call those methods and was wondering whether anyone could advise on how to proceed.
So far I found that the decorView has a method onApplyWindowInsets(), but as far as I can see I already need to pass that one an instance of android.view.WindowInsets which as far as I can tell is what I am after.
This is what I have so far:
if (app.android && device.sdkVersion >= '20') {
const window = app.android.startActivity.getWindow();
const decorView = window.getDecorView();
//decorView.onApplyWindowInsets();
}
Thanks for any pointers!
This snippet works for me, at least for detecting the bigger height of a status bar with notch.
let androidStatusBarHeight = 0;
const context = application.android.context;
const metrics = new android.util.DisplayMetrics();
context.getSystemService(android.content.Context.WINDOW_SERVICE).getDefaultDisplay().getRealMetrics(metrics);
console.log(metrics.density);
const resourceId = context.getResources().getIdentifier("status_bar_height", "dimen", "android");
if(resourceId > 0) {
console.log(context.getResources().getDimensionPixelSize(resourceId));
androidStatusBarHeight = context.getResources().getDimensionPixelSize(resourceId) / metrics.density;
} else {
androidStatusBarHeight = 24;
}
return androidStatusBarHeight;
I have some content/input fields that are covered when the android keyboard is shown in my cordova app. I have
android:windowSoftInputMode="adjustPan" and <preference name="fullscreen" value="false" />
I tried android:windowSoftInputMode="adjustResize but it kept shrinking my content because it was resizing the window (My content is sized based on viewport width and viewport height). Thank you for any suggestions!
So I had a work around myself that may or may not work for everyone, but I figured I could post this to hopefully help someone who comes across this!
I found a lot of answers but none really helped me. So in my AndroidManinfest.xml file I set android:windowSoftInputMode="adjustPan|stateHidden". Yes, this will still cover the content below the keyboard when it's opened.
To avoid that, I gave all of my scroll views that would be affected by the keyboard being shown a class of inputScrollContainer. Name them whatever you would like.
Since every container (for me) was the same height as was the top bar for each page, I did the following: (you will have to install the device plugin and the keyboard plugin from cordova
Got window.innerHeight at the beginning of my js (if you do this inside of your native.keyboardshow function, iOS will give you the resized view based on the keyboard's height)
Then, inside my native.keyboardShow function, I did the following:
- Then got the height of the top bar (I chose one as they were all the same)
- Added the added the keyboard height and top bar height together
- Then I subtracted those from the window height
Doing this now gave me the height "leftover" for the scroll view to have. After that I:
Got all elements by class name inputScrollContainer
Looped through them and assigned the new height to each (you can assign it to the only scroll view currently in view, but I only had three affected views so I wasn't worried about it)
Now the scroll view was resized to whatever was left between the top bar and the keyboard. Then on my native.keyboardhide function, I just restored the height to what the original height for all of the scroll views was before.
I'm sure there are other ways to do this, but doing it this way gave me flexibility and consistency across iOS and Android. I hope this helps someone!
To move the layout up when the keyboard is visible/shown add the following activity.
<activity android:windowSoftInputMode="adjustPan|adjustResize"> </activity>
adjustResize : The activity's main window is always resized to make room for the soft keyboard on screen.
adjustPan : The activity's main window is not resized to make room for the soft keyboard. Rather, the contents of the window are automatically panned so that the current focus is never obscured by the keyboard and users can always see what they are typing. This is generally less desirable than resizing, because the user may need to close the soft keyboard to get at and interact with obscured parts of the window.
In your scenario you can make use of adjust pan
However it works based on the android versions. It may not work in particular versions. please be find and use.
Please have look at this answer you will come to know a lot.
Viewport height is the problem here.
There is some way to correct the problem with mediaqueries, or with javascript (modifying all of your dom element with the correct height).
But in my case, I had lots of dom elements, and really didn't want to change all of this with javascript.
My trick is :
- Change all of your vh with rem and divide your value by 4
- use this little javascript in all of your page :
$("html").css({"font-size": ($(window).height()/25)+"px"});
Here we go, in this example, font-size is 4% of window height (cause font-size has a minimum value on mobile app), so :
1rem=4% of widow height=4vh
0.25rem = 1vh etc...
In my case, I use a SASS function to divide with 4 all of my vh, so it was easier to change all css. (1h = rem(1) = 0.25rem)
Hope this will help someday.
This JS option delivers a UX similar to iOS:
let events = {
android: {
keyboard: {
threshold: 300, //px
transition: 300, //ms
visible: false,
last_el: null
}
}
}
onAndroidKeyboard() {
if(is_android) {
let threshold = events.android.keyboard.threshold;
let transition = events.android.keyboard.transition;
function onIn(e) {
let target = e.target;
if(target.nodeName.toLowerCase() !== 'input') {
return false
}
let visible = events.android.keyboard.visible;
let h = window.innerHeight;
try {
let bottom = target.getBoundingClientRect().bottom;
if(bottom) {
let diff = h - bottom;
if(diff < threshold) {
if(!visible) {
let animate_amount = threshold - diff;
events.android.keyboard.visible = true;
document.body.style.transform = 'translateY(0)';
document.body.style.webkitTransition = `all ${transition}ms`;
document.body.style.transition = `all ${transition}ms`;
events.android.keyboard.visible = true;
events.android.keyboard.last_el = target;
requestAnimationFrame(function () {
document.body.style.transform = `translateY(-${animate_amount}px)`;
});
}
}
}
} catch (e) {
console.error(e);
}
}
function onOut(e) {
let visible = events.android.keyboard.visible;
if(visible) {
document.body.style.transform = 'translateY(0)';
setTimeout(function () {
requestAnimationFrame(function () {
document.body.style.removeProperty('transform');
document.body.style.removeProperty('transition');
document.body.style.removeProperty('webkitTransition');
events.android.keyboard.visible = false;
events.android.keyboard.last_el = null;
});
}, transition)
}
}
document.addEventListener('focusin', onIn, false);
document.addEventListener('focusout', onOut, false);
}
}