Skip to content

Commit 488d186

Browse files
committed
Add SoftwareKeyboardControllerCompat
Extracts the implementation of WindowInsetsControllerCompat.show and hide for the IME type into a new SoftwareKeyboardControllerCompat type. While WindowInsetsControllerCompat needs the correct Window instance for all of its functionality, the implementations (and workarounds) for showing and hiding the IME only require a View instance. By extracting these out, other implementations that want to show and hide the IME (such as Compose) can only need the View, and not the correct Window, which can be difficult when dealing with Dialog interoperability. Test: New Change-Id: I2740d94a950bad76c961975ec1899f747b203229
1 parent 8c71e1a commit 488d186

8 files changed

+594
-82
lines changed

Diff for: core/core/api/current.txt

+6
Original file line numberDiff line numberDiff line change
@@ -2716,6 +2716,12 @@ package androidx.core.view {
27162716
method public int computeVerticalScrollRange();
27172717
}
27182718

2719+
public final class SoftwareKeyboardControllerCompat {
2720+
ctor public SoftwareKeyboardControllerCompat(android.view.View);
2721+
method public void hide();
2722+
method public void show();
2723+
}
2724+
27192725
public interface TintableBackgroundView {
27202726
method public android.content.res.ColorStateList? getSupportBackgroundTintList();
27212727
method public android.graphics.PorterDuff.Mode? getSupportBackgroundTintMode();

Diff for: core/core/api/public_plus_experimental_current.txt

+6
Original file line numberDiff line numberDiff line change
@@ -2723,6 +2723,12 @@ package androidx.core.view {
27232723
method public int computeVerticalScrollRange();
27242724
}
27252725

2726+
public final class SoftwareKeyboardControllerCompat {
2727+
ctor public SoftwareKeyboardControllerCompat(android.view.View);
2728+
method public void hide();
2729+
method public void show();
2730+
}
2731+
27262732
public interface TintableBackgroundView {
27272733
method public android.content.res.ColorStateList? getSupportBackgroundTintList();
27282734
method public android.graphics.PorterDuff.Mode? getSupportBackgroundTintMode();

Diff for: core/core/api/restricted_current.txt

+6
Original file line numberDiff line numberDiff line change
@@ -3161,6 +3161,12 @@ package androidx.core.view {
31613161
method public int computeVerticalScrollRange();
31623162
}
31633163

3164+
public final class SoftwareKeyboardControllerCompat {
3165+
ctor public SoftwareKeyboardControllerCompat(android.view.View);
3166+
method public void hide();
3167+
method public void show();
3168+
}
3169+
31643170
public interface TintableBackgroundView {
31653171
method public android.content.res.ColorStateList? getSupportBackgroundTintList();
31663172
method public android.graphics.PorterDuff.Mode? getSupportBackgroundTintMode();

Diff for: core/core/src/androidTest/AndroidManifest.xml

+6
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@
122122
android:exported="true"
123123
android:theme="@android:style/Theme.Light.NoTitleBar" />
124124

125+
<activity
126+
android:name="androidx.core.view.SoftwareKeyboardControllerCompatActivity"
127+
android:exported="true"
128+
android:windowSoftInputMode="adjustResize"
129+
android:theme="@android:style/Theme.Light.NoTitleBar" />
130+
125131
<activity
126132
android:name="androidx.core.app.GetSystemLocalesActivity"
127133
android:exported="true" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Copyright 2023 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.core.view;
18+
19+
import android.support.v4.BaseTestActivity;
20+
21+
import androidx.core.test.R;
22+
23+
public class SoftwareKeyboardControllerCompatActivity extends BaseTestActivity {
24+
@Override
25+
protected int getContentViewLayoutResId() {
26+
return R.layout.insets_compat_activity;
27+
}
28+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
/*
2+
* Copyright 2023 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.core.view
18+
19+
import android.app.Dialog
20+
import android.os.Build
21+
import android.view.View
22+
import android.widget.EditText
23+
import android.widget.TextView
24+
import androidx.core.test.R
25+
import androidx.test.core.app.ActivityScenario
26+
import androidx.test.espresso.Espresso
27+
import androidx.test.espresso.action.ViewActions
28+
import androidx.test.espresso.matcher.ViewMatchers
29+
import androidx.test.ext.junit.runners.AndroidJUnit4
30+
import androidx.test.filters.LargeTest
31+
import androidx.test.filters.SdkSuppress
32+
import androidx.testutils.withActivity
33+
import java.util.concurrent.CountDownLatch
34+
import java.util.concurrent.TimeUnit
35+
import java.util.concurrent.atomic.AtomicReference
36+
import org.hamcrest.Matchers
37+
import org.junit.After
38+
import org.junit.Assert
39+
import org.junit.Assume
40+
import org.junit.Before
41+
import org.junit.Test
42+
import org.junit.runner.RunWith
43+
44+
@SdkSuppress(minSdkVersion = 23)
45+
@LargeTest
46+
@RunWith(AndroidJUnit4::class)
47+
public class SoftwareKeyboardControllerCompatActivityTest {
48+
49+
private lateinit var container: View
50+
private lateinit var softwareKeyboardControllerCompat: SoftwareKeyboardControllerCompat
51+
private lateinit var scenario: ActivityScenario<SoftwareKeyboardControllerCompatActivity>
52+
53+
@Before
54+
public fun setup() {
55+
scenario = ActivityScenario.launch(SoftwareKeyboardControllerCompatActivity::class.java)
56+
57+
container = scenario.withActivity { findViewById(R.id.container) }
58+
scenario.withActivity {
59+
softwareKeyboardControllerCompat =
60+
SoftwareKeyboardControllerCompat(container)
61+
WindowCompat.setDecorFitsSystemWindows(window, false)
62+
}
63+
// Close the IME if it's open, so we start from a known scenario
64+
Espresso.onView(ViewMatchers.withId(R.id.edittext)).perform(ViewActions.closeSoftKeyboard())
65+
}
66+
67+
@Test
68+
public fun toggleIME() {
69+
// Test do not currently work on Cuttlefish
70+
assumeNotCuttlefish()
71+
val container: View = scenario.withActivity { findViewById(R.id.container) }
72+
scenario.withActivity { findViewById<View>(R.id.edittext).requestFocus() }
73+
74+
val softwareKeyboardControllerCompat = scenario.withActivity {
75+
SoftwareKeyboardControllerCompat(container)
76+
}
77+
container.doAndAwaitNextInsets(
78+
insetsPredicate = { !it.isVisible(WindowInsetsCompat.Type.ime()) }
79+
) {
80+
scenario.onActivity { softwareKeyboardControllerCompat.hide() }
81+
}
82+
83+
container.doAndAwaitNextInsets(
84+
insetsPredicate = { it.isVisible(WindowInsetsCompat.Type.ime()) }
85+
) {
86+
scenario.onActivity { softwareKeyboardControllerCompat.show() }
87+
}
88+
89+
container.doAndAwaitNextInsets(
90+
insetsPredicate = { !it.isVisible(WindowInsetsCompat.Type.ime()) }
91+
) {
92+
scenario.onActivity { softwareKeyboardControllerCompat.hide() }
93+
}
94+
}
95+
96+
@Test
97+
public fun do_not_show_IME_if_TextView_not_focused() {
98+
val editText = scenario.withActivity {
99+
findViewById<EditText>(R.id.edittext)
100+
}
101+
102+
// We hide the edit text to ensure it won't be automatically focused
103+
scenario.onActivity {
104+
editText.visibility = View.GONE
105+
ViewMatchers.assertThat(editText.isFocused, Matchers.`is`(false))
106+
}
107+
108+
container.doAndAwaitNextInsets(
109+
insetsPredicate = {
110+
!it.isVisible(WindowInsetsCompat.Type.ime())
111+
}
112+
) {
113+
scenario.onActivity { softwareKeyboardControllerCompat.show() }
114+
}
115+
}
116+
117+
@Test
118+
fun show_IME_fromEditText() {
119+
// Test do not currently work on Cuttlefish
120+
assumeNotCuttlefish()
121+
val editText = scenario.withActivity { findViewById(R.id.edittext) }
122+
val controller = scenario.withActivity {
123+
SoftwareKeyboardControllerCompat(editText)
124+
}
125+
126+
scenario.onActivity {
127+
editText.requestFocus()
128+
controller.show()
129+
}
130+
131+
container.doAndAwaitNextInsets(
132+
insetsPredicate = {
133+
it.isVisible(WindowInsetsCompat.Type.ime())
134+
}
135+
) {
136+
scenario.onActivity {
137+
editText.requestFocus()
138+
controller.show()
139+
}
140+
}
141+
142+
ViewMatchers.assertThat(editText.isFocused, Matchers.`is`(true))
143+
}
144+
145+
@Test
146+
public fun do_not_show_IME_if_TextView_in_dialog_not_focused() {
147+
val dialog = scenario.withActivity {
148+
object : Dialog(this) {
149+
override fun onAttachedToWindow() {
150+
super.onAttachedToWindow()
151+
WindowCompat.setDecorFitsSystemWindows(window!!, false)
152+
}
153+
}.apply {
154+
setContentView(R.layout.insets_compat_activity)
155+
}
156+
}
157+
158+
val editText = dialog.findViewById<TextView>(R.id.edittext)
159+
160+
// We hide the edit text to ensure it won't be automatically focused
161+
scenario.onActivity {
162+
dialog.show()
163+
editText.visibility = View.GONE
164+
ViewMatchers.assertThat(editText.isFocused, Matchers.`is`(false))
165+
}
166+
167+
container.doAndAwaitNextInsets(
168+
insetsPredicate = {
169+
!it.isVisible(WindowInsetsCompat.Type.ime())
170+
}
171+
) {
172+
scenario.onActivity {
173+
SoftwareKeyboardControllerCompat(editText).show()
174+
}
175+
}
176+
}
177+
178+
@Test
179+
fun show_IME_fromEditText_in_dialog() {
180+
val dialog = scenario.withActivity {
181+
object : Dialog(this) {
182+
override fun onAttachedToWindow() {
183+
super.onAttachedToWindow()
184+
WindowCompat.setDecorFitsSystemWindows(window!!, false)
185+
}
186+
}.apply {
187+
setContentView(R.layout.insets_compat_activity)
188+
}
189+
}
190+
191+
val editText = dialog.findViewById<TextView>(R.id.edittext)
192+
193+
scenario.onActivity { dialog.show() }
194+
195+
val controller =
196+
SoftwareKeyboardControllerCompat(editText)
197+
198+
container.doAndAwaitNextInsets(
199+
insetsPredicate = {
200+
it.isVisible(WindowInsetsCompat.Type.ime())
201+
}
202+
) {
203+
scenario.onActivity { controller.show() }
204+
}
205+
}
206+
207+
@Test
208+
public fun hide_IME() {
209+
// Test do not currently work on Cuttlefish
210+
assumeNotCuttlefish()
211+
212+
container.doAndAwaitNextInsets(
213+
insetsPredicate = {
214+
it.isVisible(WindowInsetsCompat.Type.ime())
215+
}
216+
) {
217+
Espresso.onView(ViewMatchers.withId(R.id.edittext)).perform(ViewActions.click())
218+
}
219+
container.doAndAwaitNextInsets(
220+
insetsPredicate = {
221+
!it.isVisible(WindowInsetsCompat.Type.ime())
222+
}
223+
) {
224+
scenario.onActivity { softwareKeyboardControllerCompat.hide() }
225+
}
226+
}
227+
228+
private fun assumeNotCuttlefish() {
229+
// TODO: remove this if b/159103848 is resolved
230+
Assume.assumeFalse(
231+
"Unable to test: Cuttlefish devices default to the virtual keyboard being disabled.",
232+
Build.MODEL.contains("Cuttlefish", ignoreCase = true)
233+
)
234+
}
235+
236+
@After
237+
fun cleanup() {
238+
scenario.close()
239+
}
240+
241+
private fun View.doAndAwaitNextInsets(
242+
insetsPredicate: (WindowInsetsCompat) -> Boolean = { true },
243+
action: (View) -> Unit,
244+
): WindowInsetsCompat {
245+
val latch = CountDownLatch(1)
246+
val received = AtomicReference<WindowInsetsCompat>()
247+
248+
// Set a listener to catch WindowInsets
249+
ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets: WindowInsetsCompat ->
250+
if (insetsPredicate(insets)) {
251+
received.set(insets)
252+
latch.countDown()
253+
}
254+
255+
WindowInsetsCompat.CONSUMED
256+
}
257+
258+
scenario.onActivity { ViewCompat.requestApplyInsets(this) }
259+
260+
try {
261+
// Perform the action
262+
action(this)
263+
// Await an inset pass
264+
if (!latch.await(5, TimeUnit.SECONDS)) {
265+
Assert.fail("OnApplyWindowInsetsListener was not called")
266+
}
267+
} finally {
268+
ViewCompat.setOnApplyWindowInsetsListener(this, null)
269+
}
270+
return received.get()
271+
}
272+
}

0 commit comments

Comments
 (0)