Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP
import com.google.android.material.appbar.CollapsingToolbarLayout
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.color.MaterialColors
import com.google.android.material.shape.MaterialShapeDrawable
import com.swmansion.rnscreens.common.text.TextAppearance
import com.swmansion.rnscreens.common.text.TextAppearanceDefaults
import com.swmansion.rnscreens.ext.detachFromCurrentParent
Expand All @@ -33,6 +35,7 @@ import com.swmansion.rnscreens.stack.header.config.StackHeaderConfigurationProvi
import com.swmansion.rnscreens.stack.header.config.StackHeaderType
import com.swmansion.rnscreens.stack.header.subview.StackHeaderSubview
import com.swmansion.rnscreens.utils.dpToPx
import com.swmansion.rnscreens.utils.resolveColorAttr
import com.swmansion.rnscreens.utils.resolveDrawableAttr
import com.swmansion.rnscreens.utils.spToPx
import kotlin.math.roundToInt
Expand Down Expand Up @@ -455,6 +458,47 @@ internal class StackHeaderApplicator(
appBar.requestLayout()
}

internal fun applyBackgroundColors(
appBar: StackHeaderAppBarLayout,
config: StackHeaderConfigurationProviding,
) {
val backgroundColor =
config.backgroundColor ?: resolveColorAttr(appBar.context, R.attr.colorSurface)
val scrolledBackgroundColor =
config.scrolledBackgroundColor
?: resolveColorAttr(appBar.context, R.attr.colorSurfaceContainer)

val background =
MaterialShapeDrawable().apply {
fillColor = ColorStateList.valueOf(backgroundColor)
}

when (appBar) {
is StackHeaderAppBarLayout.Small -> {
appBar.background = background
appBar.setLiftOnScrollColor(ColorStateList.valueOf(scrolledBackgroundColor))

// The lift animation runs only on lifted-state changes; jump to the end
// state when colors change while already lifted. The end state is the
// scrolled color composited over the background (see Material's
// initializeLiftOnScrollWithColor), not the raw scrolled color — they
// differ when the scrolled color is not fully opaque.
if (appBar.isLifted) {
background.fillColor =
ColorStateList.valueOf(
MaterialColors.layer(backgroundColor, scrolledBackgroundColor),
)
}
}

is StackHeaderAppBarLayout.Collapsing -> {
appBar.setLiftOnScrollColor(null)
appBar.background = background
appBar.collapsingToolbarLayout.setContentScrimColor(scrolledBackgroundColor)
}
}
}
Comment thread
kligarski marked this conversation as resolved.

// endregion

// region Helpers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,11 @@ internal class StackHeaderCoordinatorLayout(
provider.clearInvalidationFlags(StackHeaderInvalidationFlags.SCROLL_FLAGS)
}

if (needsRebuild || provider.invalidationFlags.containsAny(StackHeaderInvalidationFlags.BACKGROUND_COLORS)) {
applicator.applyBackgroundColors(appBar, provider)
provider.clearInvalidationFlags(StackHeaderInvalidationFlags.BACKGROUND_COLORS)
}

if (needsRebuild || provider.invalidationFlags.containsAny(StackHeaderInvalidationFlags.LIFT_ON_SCROLL)) {
// Lift-on-scroll is disabled in transparent mode: there is no content
// scrolling behavior installed and the app bar overlays the content.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ internal class StackHeaderConfig(
override var liftOnScroll: Boolean by invalidatingProperty(true, StackHeaderInvalidationFlags.LIFT_ON_SCROLL)
internal set

override var backgroundColor: Int? by invalidatingProperty(null, StackHeaderInvalidationFlags.BACKGROUND_COLORS)
internal set

override var scrolledBackgroundColor: Int? by invalidatingProperty(null, StackHeaderInvalidationFlags.BACKGROUND_COLORS)
internal set

override var toolbarMenu: StackHeaderToolbarMenuConfig
by invalidatingProperty(StackHeaderToolbarMenuConfig(emptyList(), emptyList()), StackHeaderInvalidationFlags.TOOLBAR_MENU)
internal set
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,20 @@ internal open class StackHeaderConfigViewManager :
view.liftOnScroll = value
}

override fun setBackgroundColor(
view: StackHeaderConfig,
value: Int?,
) {
view.backgroundColor = value
}

override fun setScrolledBackgroundColor(
view: StackHeaderConfig,
value: Int?,
) {
view.scrolledBackgroundColor = value
}

override fun setToolbarMenuGroupDividerEnabled(
view: StackHeaderConfig,
value: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ internal interface StackHeaderConfigurationProviding {
val scrollFlagExitUntilCollapsed: Boolean
val scrollFlagSnap: Boolean
val liftOnScroll: Boolean
val backgroundColor: Int?
val scrolledBackgroundColor: Int?
val leadingSubview: StackHeaderSubviewProviding?
val centerSubview: StackHeaderSubviewProviding?
val trailingSubview: StackHeaderSubviewProviding?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ internal value class StackHeaderInvalidationFlags(
val TITLE_POSITIONING = StackHeaderInvalidationFlags(1 shl 8)
val TITLE_APPEARANCE = StackHeaderInvalidationFlags(1 shl 9)
val CONTENT_INSETS = StackHeaderInvalidationFlags(1 shl 10)
val BACKGROUND_COLORS = StackHeaderInvalidationFlags(1 shl 11)
val APPEARANCE =
TITLE or BACK_BUTTON or OVERFLOW_ICON or TITLE_POSITIONING or TITLE_APPEARANCE or CONTENT_INSETS
TITLE or BACK_BUTTON or OVERFLOW_ICON or TITLE_POSITIONING or TITLE_APPEARANCE or CONTENT_INSETS or BACKGROUND_COLORS
val ALL = STRUCTURE or SUBVIEWS or APPEARANCE or SCROLL_FLAGS or TOOLBAR_MENU or LIFT_ON_SCROLL
}

Expand Down
3 changes: 3 additions & 0 deletions apps/src/tests/single-feature-tests/stack-v5/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import TestStackHeaderMenuOptionsIOS from './test-stack-header-menu-options-ios'
import TestStackHeaderItemIdentifierIOS from './test-stack-header-item-identifier-ios';
import TestStackHeaderTitleAppearance from './test-stack-header-title-appearance-android';
import TestStackHeaderContentInsets from './test-stack-header-content-insets-android';
import TestStackHeaderBackground from './test-stack-header-background-android';

// Scenario entry-point components — each scenario's default export re-exported
// under a name for direct rendering (e.g. from App.tsx or e2e harnesses).
Expand Down Expand Up @@ -59,6 +60,7 @@ export { default as TestStackToolbarMenuBatchCommands } from './test-stack-toolb
export { default as TestStackToolbarMenuA11y } from './test-stack-toolbar-menu-a11y-android';
export { default as TestStackHeaderTitleAppearance } from './test-stack-header-title-appearance-android';
export { default as TestStackHeaderContentInsets } from './test-stack-header-content-insets-android';
export { default as TestStackHeaderBackground } from './test-stack-header-background-android';

const scenarios = {
TestStackPreventNativeDismissSingleStack,
Expand Down Expand Up @@ -88,6 +90,7 @@ const scenarios = {
TestStackToolbarMenuA11y,
TestStackHeaderTitleAppearance,
TestStackHeaderContentInsets,
TestStackHeaderBackground,
};

const StackScenarioGroup: ScenarioGroup<keyof typeof scenarios> = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Image,
PlatformColor,
ScrollView,
StyleSheet,
Text,
View,
type ColorValue,
} from 'react-native';
import { scenarioDescription } from './scenario-description';
import { createScenario } from '@apps/tests/shared/helpers';
import {
StackContainer,
useStackNavigationContext,
} from '@apps/shared/containers/stack';
import { SettingsPicker, SettingsSwitch } from '@apps/shared';
import { Colors } from '@apps/shared/styling';
import LongText from '@apps/shared/LongText';
import {
type StackHeaderConfigProps,
type StackHeaderTypeAndroid,
ScrollViewMarker,
} from 'react-native-screens';

const PLATFORM_COLOR_LIGHT = PlatformColor('@android:color/holo_green_light');
const PLATFORM_COLOR_DARK = PlatformColor('@android:color/holo_green_dark');

const options = <const T extends string>(...values: T[]): T[] => values;

const HEADER_TYPES: StackHeaderTypeAndroid[] = ['small', 'medium', 'large'];
const COLOR_OPTIONS = options(
'default',
'red',
'green',
'blue',
'translucent',
'transparent',
'platform',
);

type ColorOption = (typeof COLOR_OPTIONS)[number];

interface Config {
type: StackHeaderTypeAndroid;
backgroundColor: ColorOption;
scrolledBackgroundColor: ColorOption;
backgroundSubview: boolean;
}

const DEFAULT_CONFIG: Config = {
type: 'small',
backgroundColor: 'default',
scrolledBackgroundColor: 'default',
backgroundSubview: false,
};

function resolveBackgroundColor(value: ColorOption): ColorValue | undefined {
switch (value) {
case 'red':
return Colors.RedLight60;
case 'green':
return Colors.GreenLight60;
case 'blue':
return Colors.BlueLight60;
case 'translucent':
return Colors.NavyLightTransparent;
case 'transparent':
return 'transparent';
case 'platform':
return PLATFORM_COLOR_LIGHT;
default:
return undefined;
}
}

function resolveScrolledBackgroundColor(
value: ColorOption,
): ColorValue | undefined {
switch (value) {
case 'red':
return Colors.RedLight100;
case 'green':
return Colors.GreenLight100;
case 'blue':
return Colors.BlueLight100;
case 'translucent':
return Colors.NavyLightTransparent;
case 'transparent':
return 'transparent';
case 'platform':
return PLATFORM_COLOR_DARK;
default:
return undefined;
}
}

// The image fills only the trailing part of the header so the background color
// stays visible next to it (and behind it through the scrim when collapsed).
function TreesBackground() {
return (
<View style={styles.backgroundSubview}>
<Image
source={require('@assets/trees.jpg')}
resizeMode="cover"
style={styles.backgroundImage}
/>
</View>
);
}

function buildHeaderConfig(config: Config): StackHeaderConfigProps {
return {
title: 'Header background',
android: {
type: config.type,

// Collapsing headers re-expand as soon as the user scrolls up, so the
// tester can compare expanded/collapsed appearance from any scroll offset.
scrollFlagEnterAlways: config.type === 'small' ? undefined : true,

backgroundColor: resolveBackgroundColor(config.backgroundColor),
scrolledBackgroundColor: resolveScrolledBackgroundColor(
config.scrolledBackgroundColor,
),

// The background subview is supported only for collapsing header types.
backgroundSubview:
config.backgroundSubview && config.type !== 'small'
? { collapseMode: 'parallax', render: () => <TreesBackground /> }
: undefined,
},
};
}

function TestStackHeaderBackgroundAndroid() {
return (
<View style={styles.backdrop}>
<StackContainer
routeConfigs={[{ name: 'Home', element: <ConfigScreen /> }]}
/>
</View>
);
}

function ConfigScreen() {
const { setRouteOptions, routeKey } = useStackNavigationContext();
const [config, setConfig] = useState<Config>(DEFAULT_CONFIG);

const updateConfig = useCallback(
<K extends keyof Config>(key: K, value: Config[K]) => {
setConfig(prev => ({ ...prev, [key]: value }));
},
[],
);

const headerConfig = useMemo(() => buildHeaderConfig(config), [config]);

useEffect(() => {
setRouteOptions(routeKey, { headerConfig });
}, [headerConfig, setRouteOptions, routeKey]);

return (
<ScrollViewMarker style={styles.scrollViewMarker}>
<ScrollView
testID="header-background-scrollview"
nestedScrollEnabled
style={styles.scroll}
contentContainerStyle={styles.content}>
<Text style={styles.heading}>Header config</Text>
<SettingsPicker<StackHeaderTypeAndroid>
testID="header-type-picker"
label="type"
value={config.type}
onValueChange={v => updateConfig('type', v)}
items={HEADER_TYPES}
/>
<SettingsPicker<ColorOption>
testID="background-color-picker"
label="backgroundColor"
value={config.backgroundColor}
onValueChange={v => updateConfig('backgroundColor', v)}
items={COLOR_OPTIONS}
/>
<SettingsPicker<ColorOption>
testID="scrolled-background-color-picker"
label="scrolledBackgroundColor"
value={config.scrolledBackgroundColor}
onValueChange={v => updateConfig('scrolledBackgroundColor', v)}
items={COLOR_OPTIONS}
/>
<SettingsSwitch
testID="background-subview-switch"
label="backgroundSubview (medium/large)"
value={config.backgroundSubview}
onValueChange={v => updateConfig('backgroundSubview', v)}
/>

<Text style={styles.heading}>Scroll to observe color changes</Text>
<LongText size="xl" />
<Text testID="header-background-bottom-marker">End of content</Text>
</ScrollView>
</ScrollViewMarker>
);
}

const styles = StyleSheet.create({
// Shows through the header when header is transparent.
backdrop: {
flex: 1,
backgroundColor: Colors.PurpleLight80,
},
scrollViewMarker: {
flex: 1,
},
scroll: {
backgroundColor: Colors.cardBackground,
},
content: {
padding: 16,
gap: 6,
},
heading: {
fontSize: 20,
fontWeight: 'bold',
marginTop: 12,
marginBottom: 4,
},
backgroundSubview: {
flex: 1,
alignItems: 'flex-end',
},
backgroundImage: {
height: '100%',
width: '50%',
},
});

export default createScenario(
TestStackHeaderBackgroundAndroid,
scenarioDescription,
);
Loading
Loading