Skip to content

Commit 35396ef

Browse files
authored
[expo-image-picker] Handle edge-to-edge (expo#44208)
# Why Closes expo#44164 The `expo-image-picker` `ExpoCropImageActivity` doesn't handle edge-to-edge display properly on Android. The crop view overlaps with system bars and display cutouts, and the status bar theming uses the deprecated `window.statusBarColor` API (that has no effect on Android 16+). # How - Enabled edge-to-edge rendering with `WindowCompat.enableEdgeToEdge`. - Added window insets listeners to properly inset the `CropImageView` margins so it avoids system bars. - Replaced the deprecated `window.statusBarColor` with a custom status bar background view sized dynamically via insets. - Apply both status and navigation bar style. - Inlined the palette/theming logic from `ExpoCropImageUtils` directly into `ExpoCropImageActivity`, removing the now-unused `applyPaletteToOptions` and `applyWindowTheming` utility methods. - Added `androidx.core:core-ktx:1.17.0` dependency for `enableEdgeToEdge` / `updateLayoutParams`. # Test Plan - Open the image picker with cropping enabled on an Android device with edge-to-edge display (Android 15+). Verify the crop view doesn't overlap with the status bar, navigation bar, or display cutouts. Test in both light and dark modes. - Do the same test on Android < 15. It should be edge-to-edge too. # Checklist - [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) # Screenshots ### Android 16 - Light mode <img width="265" height="489" alt="Screenshot 2026-03-24 at 20 22 34" src="https://github.com/user-attachments/assets/56754ea7-3e85-4780-b453-73968263c697" /> ### Android 16 - Dark mode <img width="265" height="489" alt="Screenshot 2026-03-24 at 20 25 35" src="https://github.com/user-attachments/assets/cbd5c9d9-282b-4e31-bae1-c1fb3bde1329" /> ### Android 16 - Custom colors <img width="265" height="489" alt="Screenshot 2026-03-24 at 20 27 21" src="https://github.com/user-attachments/assets/c60376c7-cce4-4b6f-97e2-47a2a71d38ea" /> ### Android 14 - Light mode <img width="260" height="483" alt="Screenshot 2026-03-24 at 20 29 50" src="https://github.com/user-attachments/assets/bff85951-423a-4d66-a18b-95a8239ea47e" />
1 parent f094314 commit 35396ef

4 files changed

Lines changed: 99 additions & 67 deletions

File tree

packages/expo-image-picker/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
### 🐛 Bug fixes
1212

13+
- [android] Handle edge-to-edge display in crop activity. ([#44208](https://github.com/expo/expo/pull/44208) by [@zoontek](https://github.com/zoontek))
1314
- fix potential `null` mime type reported ([#43734](https://github.com/expo/expo/pull/43734) by [@vonovak](https://github.com/vonovak))
1415
- [android] fix cropper default colors in light mode ([#42437](https://github.com/expo/expo/pull/42437) by [@fobos531](https://github.com/fobos531))
1516
- [iOS] Fix `base64` result not being a JPEG data. ([#43806](https://github.com/expo/expo/pull/43806) by [@barthap](https://github.com/barthap))

packages/expo-image-picker/android/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ android {
2121
dependencies {
2222
implementation "androidx.activity:activity-ktx:1.11.0"
2323
implementation "androidx.appcompat:appcompat:1.7.1"
24+
implementation "androidx.core:core-ktx:1.17.0"
2425
implementation "androidx.exifinterface:exifinterface:1.4.1"
2526
implementation "com.vanniktech:android-image-cropper:4.7.0"
2627
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"

packages/expo-image-picker/android/src/main/java/expo/modules/imagepicker/ExpoCropImageActivity.kt

Lines changed: 96 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,18 @@ package expo.modules.imagepicker
22

33
import android.graphics.Color
44
import android.view.Menu
5+
import android.view.View
6+
import android.view.ViewGroup
7+
import androidx.core.view.ViewCompat
8+
import androidx.core.view.WindowCompat
9+
import androidx.core.view.WindowInsetsCompat
10+
import androidx.core.view.WindowInsetsControllerCompat
11+
import androidx.core.view.updateLayoutParams
512
import com.canhub.cropper.CropImageActivity
613
import com.canhub.cropper.CropImageOptions
14+
import com.canhub.cropper.CropImageView
15+
import expo.modules.imagepicker.ExpoCropImageUtils.getColorResource
16+
import expo.modules.imagepicker.ExpoCropImageUtils.getThemeColor
717

818
/**
919
* A wrapper around `CropImageActivity` to provide custom theming and functionality.
@@ -15,6 +25,7 @@ import com.canhub.cropper.CropImageOptions
1525
*/
1626
class ExpoCropImageActivity : CropImageActivity() {
1727
private var currentIconColor: Int = Color.BLACK
28+
private var cropImageViewRef: CropImageView? = null
1829

1930
// region Lifecycle Methods
2031
override fun onCreate(savedInstanceState: android.os.Bundle?) {
@@ -23,12 +34,21 @@ class ExpoCropImageActivity : CropImageActivity() {
2334
// the toolbar and menu icons are tinted correctly on first render.
2435
getCropOptions()?.let { opts ->
2536
val isNight = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES
26-
applyPalette(isNight, opts)
37+
applyCustomization(isNight, opts)
2738
invokeSetCustomizations()
2839
invalidateOptionsMenu() // Recreate the menu to apply the new icon colors.
2940
}
3041
}
3142

43+
override fun onDestroy() {
44+
ViewCompat.setOnApplyWindowInsetsListener(window.decorView, null)
45+
46+
cropImageViewRef?.let { ViewCompat.setOnApplyWindowInsetsListener(it, null) }
47+
cropImageViewRef = null
48+
49+
super.onDestroy()
50+
}
51+
3252
override fun onCreateOptionsMenu(menu: Menu): Boolean {
3353
val result = super.onCreateOptionsMenu(menu)
3454
tintAllMenuItems(menu)
@@ -40,22 +60,88 @@ class ExpoCropImageActivity : CropImageActivity() {
4060
tintAllMenuItems(menu)
4161
return result
4262
}
63+
64+
override fun setCropImageView(cropImageView: CropImageView) {
65+
super.setCropImageView(cropImageView)
66+
67+
cropImageViewRef = cropImageView
68+
69+
// Inset the crop view margins so it doesn't overlap with system bars or display cutouts
70+
ViewCompat.setOnApplyWindowInsetsListener(cropImageView) { view, insets ->
71+
val values = insets.getInsets(
72+
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
73+
74+
view.updateLayoutParams<ViewGroup.MarginLayoutParams> {
75+
setMargins(values.left, values.top, values.right, values.bottom)
76+
}
77+
78+
insets
79+
}
80+
}
81+
4382
// endregion
4483

45-
private fun applyPalette(isNight: Boolean, opts: CropImageOptions) {
46-
// Apply palette to options and get the toolbar widget color
47-
val toolbarWidgetColor = ExpoCropImageUtils.applyPaletteToOptions(theme, resources, isNight, opts)
84+
private fun applyCustomization(isNight: Boolean, options: CropImageOptions) {
85+
val defaultBackgroundColor = if (isNight) Color.BLACK else Color.WHITE
86+
val defaultContentColor = if (isNight) Color.WHITE else Color.BLACK
4887

49-
// Set the current icon color for menu tinting
50-
currentIconColor = toolbarWidgetColor
88+
// Try theme attributes first, then fall back to color resources
89+
val expoCropBackButtonIconColor = getThemeColor(theme, R.attr.expoCropBackButtonIconColor)
90+
?: getColorResource(resources, R.color.expoCropBackButtonIconColor)
91+
val expoCropBackgroundColor = getThemeColor(theme, R.attr.expoCropBackgroundColor)
92+
?: getColorResource(resources, R.color.expoCropBackgroundColor)
93+
val expoCropToolbarActionTextColor = getThemeColor(theme, R.attr.expoCropToolbarActionTextColor)
94+
?: getColorResource(resources, R.color.expoCropToolbarActionTextColor)
95+
val expoCropToolbarColor = getThemeColor(theme, R.attr.expoCropToolbarColor)
96+
?: getColorResource(resources, R.color.expoCropToolbarColor)
97+
val expoCropToolbarIconColor = getThemeColor(theme, R.attr.expoCropToolbarIconColor)
98+
?: getColorResource(resources, R.color.expoCropToolbarIconColor)
99+
100+
val activityBackgroundColor = expoCropBackgroundColor ?: defaultBackgroundColor
101+
val toolbarColor = expoCropToolbarColor ?: defaultBackgroundColor
102+
val toolbarIconColor = expoCropToolbarIconColor ?: defaultContentColor
51103

52-
// Set up toolbar color with fallback for status bar theming
53-
val defaultToolbarColor = if (isNight) Color.BLACK else Color.WHITE
54-
val toolbarColor = opts.toolbarColor ?: defaultToolbarColor
55-
ExpoCropImageUtils.applyWindowTheming(window, toolbarColor, isNight)
104+
// Set the current icon color for menu tinting
105+
currentIconColor = toolbarIconColor
56106

57107
// Remove action bar elevation for a flat design
58108
supportActionBar?.elevation = 0f
109+
110+
options.activityBackgroundColor = activityBackgroundColor
111+
options.activityMenuIconColor = toolbarIconColor
112+
options.activityMenuTextColor = expoCropToolbarActionTextColor ?: defaultContentColor
113+
options.toolbarBackButtonColor = expoCropBackButtonIconColor ?: toolbarIconColor
114+
options.toolbarColor = toolbarColor
115+
options.toolbarTitleColor = toolbarIconColor
116+
117+
window.run {
118+
// Create a view that will sit behind the status bar, colored to match the toolbar
119+
val statusBarView = View(context).apply { setBackgroundColor(toolbarColor) }
120+
121+
// Draw content edge-to-edge, behind system bars
122+
WindowCompat.enableEdgeToEdge(this)
123+
124+
// Set system bar icon colors based on the current theme (dark icons on light bg, and vice versa)
125+
WindowInsetsControllerCompat(this, decorView).run {
126+
isAppearanceLightStatusBars = !isNight
127+
isAppearanceLightNavigationBars = !isNight
128+
}
129+
130+
// Set the root background color so it shows through transparent system bars
131+
decorView.setBackgroundColor(activityBackgroundColor)
132+
133+
// Add the status bar view with zero initial height (will be sized by insets listener below)
134+
addContentView(statusBarView,
135+
ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0))
136+
137+
// Dynamically resize the status bar view to match the actual status bar / display cutout height
138+
ViewCompat.setOnApplyWindowInsetsListener(decorView) { _, insets ->
139+
val values = insets.getInsets(
140+
WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.displayCutout())
141+
statusBarView.updateLayoutParams { height = values.top }
142+
insets
143+
}
144+
}
59145
}
60146

61147
// region Helper Methods
Lines changed: 1 addition & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
package expo.modules.imagepicker
22

3-
import android.graphics.Color
3+
import android.content.res.Resources
44
import android.util.TypedValue
5-
import com.canhub.cropper.CropImageOptions
6-
import androidx.core.view.WindowInsetsControllerCompat
75

86
/**
97
* Utility functions for ExpoCropImageActivity theming and color management.
@@ -30,58 +28,4 @@ object ExpoCropImageUtils {
3028
fun getColorResource(resources: android.content.res.Resources, colorResId: Int): Int? = runCatching {
3129
resources.getColor(colorResId, null)
3230
}.getOrNull()
33-
34-
/**
35-
* Applies a color palette to CropImageOptions based on theme attributes or color resources.
36-
* @param theme The theme to resolve colors from
37-
* @param resources The resources to get colors from
38-
* @param isNight Whether the app is in dark mode
39-
* @param options The CropImageOptions to apply colors to
40-
* @return The toolbar widget color that was applied
41-
*/
42-
fun applyPaletteToOptions(
43-
theme: android.content.res.Resources.Theme,
44-
resources: android.content.res.Resources,
45-
isNight: Boolean,
46-
options: CropImageOptions
47-
): Int {
48-
// Try theme attributes first, then fall back to color resources
49-
val customToolbar = getThemeColor(theme, R.attr.expoCropToolbarColor)
50-
?: getColorResource(resources, R.color.expoCropToolbarColor)
51-
val customIconColor = getThemeColor(theme, R.attr.expoCropToolbarIconColor)
52-
?: getColorResource(resources, R.color.expoCropToolbarIconColor)
53-
val customActionTextColor = getThemeColor(theme, R.attr.expoCropToolbarActionTextColor)
54-
?: getColorResource(resources, R.color.expoCropToolbarActionTextColor)
55-
val customBackButtonIconColor = getThemeColor(theme, R.attr.expoCropBackButtonIconColor)
56-
?: getColorResource(resources, R.color.expoCropBackButtonIconColor)
57-
val customBg = getThemeColor(theme, R.attr.expoCropBackgroundColor)
58-
?: getColorResource(resources, R.color.expoCropBackgroundColor)
59-
60-
val defaultColor = if (isNight) Color.BLACK else Color.WHITE
61-
val toolbarWidgetColor = customIconColor ?: if (isNight) Color.WHITE else Color.BLACK
62-
63-
options.activityBackgroundColor = customBg ?: defaultColor
64-
options.toolbarColor = customToolbar ?: defaultColor
65-
options.toolbarTitleColor = toolbarWidgetColor
66-
options.toolbarBackButtonColor = customBackButtonIconColor ?: toolbarWidgetColor
67-
options.activityMenuIconColor = toolbarWidgetColor
68-
options.activityMenuTextColor = customActionTextColor ?: if (isNight) Color.WHITE else Color.BLACK
69-
70-
return toolbarWidgetColor
71-
}
72-
73-
/**
74-
* Applies window-level theming (status bar, etc.) based on the applied palette.
75-
* @param window The window to apply theming to
76-
* @param toolbarColor The toolbar color that was applied
77-
* @param isNight Whether the app is in dark mode
78-
*/
79-
fun applyWindowTheming(
80-
window: android.view.Window,
81-
toolbarColor: Int,
82-
isNight: Boolean
83-
) {
84-
window.statusBarColor = toolbarColor
85-
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = !isNight
86-
}
8731
}

0 commit comments

Comments
 (0)