Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
private var applyingNativeValue = false
private var desiredLineHeightPx = 0
private var lastContentHeight = 0
private var lastContentWidth = 0
private var contentInsetVertical = 0
private var tokensJson = "[]"
private var tokens: List<ComposerToken> = emptyList()
Expand Down Expand Up @@ -424,12 +425,19 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
}

private fun emitContentSizeIfNeeded() {
val height = editor.layout?.height ?: editor.measuredHeight
val contentHeight = height + contentInsetVertical * 2
if (contentHeight == lastContentHeight) return
// The laid-out text without insets. A one-line draft reports its natural width, so
// JS can tell whether it fits a narrower row than the one it is laid out in; past
// one line only the height matters. Trailing whitespace hangs past a line without
// wrapping it, so getLineMax leaves it out.
val layout = editor.layout
val contentHeight = layout?.height ?: editor.measuredHeight
val contentWidth = if (layout?.lineCount == 1) layout.getLineMax(0).toInt() else 0
if (contentHeight == lastContentHeight && contentWidth == lastContentWidth) return
lastContentHeight = contentHeight
lastContentWidth = contentWidth
val density = resources.displayMetrics.density
onComposerContentSizeChange(
mapOf("height" to contentHeight / resources.displayMetrics.density),
mapOf("width" to contentWidth / density, "height" to contentHeight / density),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1230,7 +1230,28 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
}

private func emitContentSizeIfNeeded() {
let nextSize = textView.contentSize
// The laid-out text without insets. A one-line draft reports its natural width, so
// JS can tell whether it fits a narrower row than the one it is laid out in; past
// one line only the height matters, so layout stops at the second line. The width
// comes from the line fragment because usedRect spans the container once the view
// scrolls. A trailing newline's empty line only exists as the extra fragment.
let layout = textView.layoutManager
let container = textView.textContainer
var lines = 0
var width: CGFloat = 0
var height: CGFloat = 0
let glyphs = NSRange(location: 0, length: layout.numberOfGlyphs)
layout.enumerateLineFragments(forGlyphRange: glyphs) { _, used, _, _, stop in
lines += 1
width = used.width
height = used.maxY
if lines > 1 { stop.pointee = true }
}
if lines < 2, layout.extraLineFragmentTextContainer === container {
lines += 1
height = max(height, layout.extraLineFragmentUsedRect.maxY)
}
let nextSize = CGSize(width: lines == 1 ? ceil(width) : 0, height: ceil(height))
guard abs(nextSize.width - lastContentSize.width) > 0.5 ||
abs(nextSize.height - lastContentSize.height) > 0.5 else {
return
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
"react-native-pdf-jsi": "5.0.0",
"react-native-reanimated": "4.7.1",
"react-native-safe-area-context": "~5.10.1",
"react-native-screens": "file:deps/react-native-screens-5.0.0-t3.29.tgz",
"react-native-screens": "file:deps/react-native-screens-5.0.0-t3.30.tgz",
"react-native-shiki-engine": "^0.3.12",
"react-native-svg": "15.16.0",
"react-native-webview": "14.0.1",
Expand Down
123 changes: 76 additions & 47 deletions apps/mobile/src/components/ComposerToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
type ViewStyle,
} from "react-native";

import Animated, { FadeIn, LinearTransition } from "react-native-reanimated";

import { cn } from "../lib/cn";
import { AppText as Text } from "./ui/AppText";
import { SymbolView } from "./ui/AppSymbol";
Expand All @@ -21,6 +23,10 @@ import { useAndroidControlSizing } from "./ui/useAndroidControlSizing";
const COMPOSER_TOOLBAR_GAP = 8;
const COMPOSER_TOOLBAR_FADE_WIDTH = 18;
const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4;
const INLINE_CONTROL_LABEL_ENTERING = FadeIn.duration(160);
// Android runs no composer layout transitions, so the toolbar never trails the dock.
const INLINE_CONTROL_WIDTH_TRANSITION =
Platform.OS === "android" ? undefined : LinearTransition.duration(180);

export function ComposerInlineControl(props: {
readonly accessibilityHint?: string;
Expand All @@ -30,6 +36,8 @@ export function ComposerInlineControl(props: {
readonly icon?: ComponentProps<typeof SymbolView>["name"];
readonly renderIcon?: (size: number) => ReactNode;
readonly label: string;
/** Show only the icon and chevron; the label still names the control for accessibility. */
readonly iconOnly?: boolean;
readonly maxWidth?: ViewStyle["maxWidth"];
readonly onPress?: () => void;
readonly selected?: boolean;
Expand All @@ -38,56 +46,77 @@ export function ComposerInlineControl(props: {
readonly showChevron?: boolean;
}) {
const { scale, smallIconSize } = useAndroidControlSizing();
// The label fades in as the control widens, and leaves at once: an exiting view keeps
// drawing at its old place while the chevron moves into it. The clip keeps content laid out
// at the final width inside the frame while it eases there.
const animatesLabel = props.iconOnly !== undefined;
return (
<Pressable
accessibilityLabel={props.accessibilityLabel ?? props.label}
accessibilityHint={props.accessibilityHint}
accessibilityRole={props.static ? undefined : "button"}
accessibilityState={
props.static ? undefined : { disabled: props.disabled, selected: props.selected }
}
className="h-11 flex-row items-center gap-2 rounded-xl px-2 active:bg-subtle"
disabled={props.disabled || props.static}
onPress={props.onPress}
style={{ maxWidth: props.maxWidth ?? 190, opacity: props.disabled ? 0.45 : 1 }}
<Animated.View
layout={animatesLabel ? INLINE_CONTROL_WIDTH_TRANSITION : undefined}
style={{
maxWidth: props.maxWidth ?? 190,
overflow: animatesLabel ? "hidden" : undefined,
}}
>
{props.renderIcon ? (
<View
className="size-4 shrink-0 items-center justify-center"
style={Platform.OS === "android" ? { width: 14 * scale, height: 14 * scale } : undefined}
>
{props.renderIcon(smallIconSize)}
</View>
) : props.icon ? (
<SymbolView
name={props.icon}
size={smallIconSize}
tintColorClassName={
props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"
}
type="monochrome"
/>
) : null}
<Text
className={cn(
"shrink text-sm font-t3-medium",
props.emphasized || props.selected ? "text-foreground" : "text-foreground-muted",
)}
numberOfLines={1}
<Pressable
accessibilityLabel={props.accessibilityLabel ?? props.label}
accessibilityHint={props.accessibilityHint}
accessibilityRole={props.static ? undefined : "button"}
accessibilityState={
props.static ? undefined : { disabled: props.disabled, selected: props.selected }
}
className="h-11 flex-row items-center gap-2 rounded-xl px-2 active:bg-subtle"
disabled={props.disabled || props.static}
onPress={props.onPress}
style={{ opacity: props.disabled ? 0.45 : 1 }}
>
{props.label}
</Text>
{props.showChevron === false ? null : (
<SymbolView
name={props.chevronDirection === "right" ? "chevron.right" : "chevron.down"}
size={Math.round(10 * scale)}
tintColorClassName={
props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"
}
type="monochrome"
/>
)}
</Pressable>
{props.renderIcon ? (
<View
className="size-4 shrink-0 items-center justify-center"
style={
Platform.OS === "android" ? { width: 14 * scale, height: 14 * scale } : undefined
}
>
{props.renderIcon(smallIconSize)}
</View>
) : props.icon ? (
<SymbolView
name={props.icon}
size={smallIconSize}
tintColorClassName={
props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"
}
type="monochrome"
/>
) : null}
{props.iconOnly ? null : (
<Animated.View
className="shrink"
entering={animatesLabel ? INLINE_CONTROL_LABEL_ENTERING : undefined}
>
<Text
className={cn(
"text-sm font-t3-medium",
props.emphasized || props.selected ? "text-foreground" : "text-foreground-muted",
)}
numberOfLines={1}
>
{props.label}
</Text>
</Animated.View>
)}
{props.showChevron === false ? null : (
<SymbolView
name={props.chevronDirection === "right" ? "chevron.right" : "chevron.down"}
size={Math.round(10 * scale)}
tintColorClassName={
props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"
}
type="monochrome"
/>
)}
</Pressable>
</Animated.View>
);
}

Expand Down
26 changes: 25 additions & 1 deletion apps/mobile/src/components/ui/AndroidScreenHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AppActivityIndicator } from "./AppActivityIndicator";
import { Column, Text as NativeText, RNHostView } from "@expo/ui/jetpack-compose";
import { Column, Row, Text as NativeText, RNHostView } from "@expo/ui/jetpack-compose";
import { weight } from "@expo/ui/jetpack-compose/modifiers";
import { useState, type ReactNode } from "react";
import { View } from "react-native";

Expand Down Expand Up @@ -43,6 +44,8 @@ export function AndroidScreenHeader(props: {
readonly onBack?: () => void;
readonly embedded?: boolean;
readonly hideBottomBorder?: boolean;
/** One row with the subtitle after the title, for screens short on height. */
readonly compact?: boolean;
}) {
const { colors } = useControlTheme();
const titleTypography = useScaledTextRole("title");
Expand All @@ -65,6 +68,7 @@ export function AndroidScreenHeader(props: {
}}
>
<NativeAppBar
singleLine={props.compact}
navigationIcon={
props.onBack || props.leading ? (
<View collapsable={false} className="flex-row items-center">
Expand Down Expand Up @@ -95,6 +99,26 @@ export function AndroidScreenHeader(props: {
>
{props.title}
</NativeText>
) : props.compact ? (
<Row verticalAlignment="center" horizontalArrangement={{ spacedBy: 8 }}>
<NativeText
color={colors["--color-header-foreground"]}
maxLines={1}
overflow="ellipsis"
style={titleTypography}
>
{props.title}
</NativeText>
<NativeText
color={colors["--color-foreground-muted"]}
maxLines={1}
overflow="ellipsis"
style={subtitleTypography}
modifiers={[weight(1)]}
>
{props.subtitle}
</NativeText>
</Row>
) : (
<Column>
<NativeText
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/components/ui/AppBar.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export function AppBar(props: AppBarProps) {
onBack={props.onBack}
leading={props.sidebar !== false ? <AndroidWorkspaceSidebarButton /> : undefined}
hideBottomBorder={props.hideBottomBorder}
compact={props.compact}
actions={
search
? [
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/ui/AppBar.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export interface AppBarProps {
readonly onPress?: () => void;
};
readonly hideBottomBorder?: boolean;
/** Android: one title row with the subtitle inline. iOS ignores it. */
readonly compact?: boolean;
readonly trailing?: ReactNode;
readonly matchSearchSurface?: boolean;
readonly options?: AppNativeStackNavigationOptions;
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/components/ui/NativeAppBar.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ export function NativeAppBar(props: {
readonly title: ReactElement;
readonly navigationIcon?: ReactElement;
readonly actions?: ReactElement;
/** Shorter bar for a one-row title. */
readonly singleLine?: boolean;
}) {
const { colors } = useControlTheme();
const layout = useToolbarLayout();
const layout = useToolbarLayout(false, props.singleLine);
return (
// The bar's height is fixed, so React Native lays out the screen below it from the first frame
// instead of at zero height until Compose has measured and reported the bar.
Expand Down
11 changes: 8 additions & 3 deletions apps/mobile/src/components/ui/useToolbarLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,21 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useScaledTextRole } from "../../features/settings/appearance/useScaledTextRole";
import { useAndroidControlSizing } from "./useAndroidControlSizing";

/** Shared Android header geometry; class-based spacing uses the app's 14dp rem. */
export function useToolbarLayout(embedded = false) {
/**
* Shared Android header geometry; class-based spacing uses the app's 14dp rem.
* `singleLine` sizes the toolbar for a title row with the subtitle inline.
*/
export function useToolbarLayout(embedded = false, singleLine = false) {
const insets = useSafeAreaInsets();
const title = useScaledTextRole("title");
const subtitle = useScaledTextRole("label");
const { scale } = useAndroidControlSizing();
const { fontScale } = useWindowDimensions();
return {
height: Math.ceil(
Math.max(48, 56 * scale, (title.lineHeight + subtitle.lineHeight) * fontScale + 1),
singleLine
? Math.max(48, 48 * scale, title.lineHeight * fontScale + 1)
: Math.max(48, 56 * scale, (title.lineHeight + subtitle.lineHeight) * fontScale + 1),
),
paddingTop: embedded ? 8 * scale : Math.max(insets.top, 12 * scale),
paddingBottom: 7 * scale,
Expand Down
Loading
Loading