Skip to main content

Screen Layout Patterns

This document describes the standardized layout components and patterns for building consistent screens in Sanctiv.

Overview

All screens should use the layout components from the design system (../design-system/components). These components handle:
  • Safe area insets automatically
  • Tab bar offsets for screens inside tab navigator
  • Keyboard avoiding behavior
  • Consistent header styling

Screen Placement Standard

Critical Decision: Where should screens live in the navigation hierarchy?

The Rule

Screen TypePlacementRoute Example
Tab root (list/browse)Inside tabs/(tabs)/companions
Detail viewsInside tabs/(tabs)/companions/[id]
Contextual screens (chat)Inside tabs/(tabs)/companions/chat/[id]
Create formsOutside tabs/companions/add
Edit formsOutside tabs/companions/edit/[id]
Global featuresOutside tabs/profile, /guided-journal

Why This Standard?

  1. Clean tab bar behavior - No workarounds needed to hide tab bar
  2. Consistent UX - All forms look identical (full-screen, ActionBottomBar)
  3. Simpler code - No conditional tab bar hiding logic
  4. Industry best practice - Follows Apple HIG for focused tasks

Decision Tree: Inside or Outside Tabs?

Is the user performing a focused task (create/edit/configure)?
├── Yes → Place OUTSIDE tabs (root level)
│   └── Use ScreenContainer variant="default"
│   └── Use ActionBottomBar (no variant needed)
│   └── Use ScreenHeader variant="close"
└── No → Is it browsing/viewing content?
    ├── Yes → Place INSIDE tabs
    │   └── Use ScreenContainer variant="tab"
    │   └── Use ScreenHeader variant="back" or "none"
    └── No → Probably outside tabs (profile, settings, etc.)

Current Route Structure

app/
├── _layout.tsx              # Root stack
├── index.tsx
├── profile.tsx              # Outside tabs - global feature
├── guided-journal.tsx       # Outside tabs - focused task
├── companions/
│   └── add.tsx              # Outside tabs - create form
├── habits/
│   └── create.tsx           # Outside tabs - create form
└── (tabs)/
    ├── _layout.tsx          # Tab navigator
    └── companions/
        ├── index.tsx        # Inside tabs - list/browse
        ├── [id].tsx         # Inside tabs - detail view
        └── chat/
            └── [id].tsx     # Inside tabs - contextual

Examples

Import Path Patterns: This project uses the @/ path alias (configured in apps/mobile/tsconfig.json@/* maps to src/*). Route files in app/ typically use the alias (@/screens, @/components), while component examples in this doc use relative paths (../design-system/components) for clarity. Prefer the alias when available; use relative paths when the alias isn’t accessible or when documenting component relationships.
Create Form (Outside Tabs):
// app/companions/add.tsx
import AddCompanionScreen from "@/screens/AddCompanionScreen";
export default AddCompanionScreen;

// src/screens/AddCompanionScreen.tsx
const AddCompanionScreen = () => {
  return (
    <ScreenContainer variant="default" keyboardAvoiding>
      <ScreenHeader
        title="Add Companion"
        variant="close"
        onBack={() => router.back()}
      />
      <ScrollView contentContainerStyle={{ paddingBottom: CONTENT_PADDING_WITH_ACTION_BAR }}>
        {/* Form fields */}
      </ScrollView>
      <ActionBottomBar
        onAction={handleSubmit}
        onCancel={() => router.back()}
      />
    </ScreenContainer>
  );
};
Detail View (Inside Tabs):
// app/(tabs)/companions/[id].tsx
const CompanionProfileScreen = () => {
  return (
    <ScreenContainer variant="tab">
      <ScreenHeader
        title="Companion Profile"
        variant="back"
        onBack={() => router.back()}
      />
      <ScrollView>
        {/* Profile content */}
      </ScrollView>
    </ScreenContainer>
  );
};

Components

ScreenContainer

The foundation wrapper for all screens. Handles SafeAreaView, KeyboardAvoidingView, and tab bar offsets.
import { ScreenContainer } from '../design-system/components';

// Default screen (outside tabs)
<ScreenContainer>
  <ScreenHeader title="Settings" variant="back" />
  <ScrollView>...</ScrollView>
</ScreenContainer>

// Screen inside tab navigator
<ScreenContainer variant="tab">
  <ScreenHeader title="Journal" />
  <FlatList ... />
</ScreenContainer>

// Modal with keyboard support
<ScreenContainer variant="modal" keyboardAvoiding>
  <ScreenHeader title="New Entry" variant="close" />
  <ScrollView>...</ScrollView>
</ScreenContainer>
Props:
PropTypeDefaultDescription
variant'default' | 'tab' | 'modal''default'Screen context for safe area/offset handling
keyboardAvoidingbooleanfalseEnable KeyboardAvoidingView
backgroundColorstringcolors.neutral.whiteBackground color
testIDstring-Test identifier

ScreenHeader

Standardized header following Apple Human Interface Guidelines.
import { ScreenHeader } from '../design-system/components';

// Stack navigation with back button
<ScreenHeader
  title="Settings"
  variant="back"
  onBack={() => router.back()}
/>

// Modal with close button
<ScreenHeader
  title="New Entry"
  variant="close"
  onBack={() => router.back()}
/>

// Tab root screen (no back button)
<ScreenHeader title="Journal" variant="none" />

// With right action
<ScreenHeader
  title="Profile"
  variant="back"
  onBack={() => router.back()}
  rightAction={<IconButton icon="settings-outline" onPress={openSettings} />}
/>
Props:
PropTypeDefaultDescription
titlestringRequiredScreen title (centered)
variant'back' | 'close' | 'none''back'Left navigation variant
onBack() => void-Required for ‘back’/‘close’ variants
rightActionReactNode-Optional right-side element
subtitlestring-Optional subtitle below title
showBorderbooleantrueShow bottom border

ActionBottomBar

Fixed bottom action bar for forms and primary actions.
import { ActionBottomBar } from '../design-system/components';

// Default usage
<ActionBottomBar
  onAction={handleSave}
  actionLabel="Save"
  onCancel={() => router.back()}
/>

// Inside tab navigator
<ActionBottomBar
  variant="tab"
  onAction={handleSubmit}
  actionLabel="Submit"
  isLoading={loading}
  isActionDisabled={!isValid}
/>
Props:
PropTypeDefaultDescription
onAction() => voidRequiredPrimary action callback
actionLabelstring'Save'Primary button label
onCancel() => void-Cancel button callback
cancelLabelstring'Cancel'Cancel button label
variant'default' | 'tab''default'Adds tab bar offset
isLoadingbooleanfalseShow loading state
isActionDisabledbooleanfalseDisable primary button
showCancelbooleantrueShow cancel button

Layout Constants

Centralized layout values in constants/layout.ts: Preferred (path alias):
import { TAB_BAR_HEIGHT, HEADER_HEIGHT, CONTENT_PADDING_WITH_ACTION_BAR } from '@/constants/layout';
Relative paths (when alias not available):
// From src/screens or src/components
import { TAB_BAR_HEIGHT } from '../constants/layout';

// From src/design-system/components (deeper nesting)
import { HEADER_HEIGHT } from '../../constants/layout';
Usage example:
// For manual scroll padding when ActionBottomBar is present
<ScrollView contentContainerStyle={{ paddingBottom: CONTENT_PADDING_WITH_ACTION_BAR }}>
Note: Prefer the path alias @/constants/layout when available. Use relative paths (../ or ../../) only when the alias is not configured or not accessible from your file location.
ConstantValueDescription
TAB_BAR_HEIGHT49iOS tab bar height
HEADER_HEIGHT56Standard header height
ACTION_BAR_HEIGHT88ActionBottomBar height
MIN_TOUCH_TARGET44Apple HIG minimum (44pt)
CONTENT_PADDING16Standard horizontal padding
CONTENT_PADDING_WITH_ACTION_BAR120Padding when action bar present
CONTENT_PADDING_WITH_TAB_AND_ACTION_BAR169Padding for tab + action bar

Patterns

Tab Root Screen

Tab root screens typically have custom headers with actions:
const HabitsScreen = () => {
  return (
    <ScreenContainer variant="tab">
      {/* Custom header with Add action */}
      <View style={styles.header}>
        <Text style={styles.title}>My Habits</Text>
        <Pressable onPress={() => router.push('/habits/create')}>
          <Text style={styles.addButton}>Add</Text>
        </Pressable>
      </View>
      
      <FlatList ... />
    </ScreenContainer>
  );
};
Forms should be placed outside tabs for clean, focused experience:
// Route: /companions/add (NOT /(tabs)/companions/add)
const AddCompanionScreen = () => {
  return (
    <ScreenContainer variant="default" keyboardAvoiding>
      <ScreenHeader
        title="Add Companion"
        variant="close"
        onBack={() => router.back()}
      />
      
      <ScrollView contentContainerStyle={{ paddingBottom: CONTENT_PADDING_WITH_ACTION_BAR }}>
        {/* Form fields */}
      </ScrollView>
      
      <ActionBottomBar
        onAction={handleSubmit}
        onCancel={() => router.back()}
      />
    </ScreenContainer>
  );
};
Note: See Screen Placement Standard for why forms should be outside tabs.
Full-screen modals:
const CreateHabitScreen = () => {
  return (
    <ScreenContainer variant="default">
      <ScreenHeader
        title="Create Habit"
        variant="close"
        onBack={() => router.back()}
      />
      
      <ScrollView contentContainerStyle={{ paddingBottom: CONTENT_PADDING_WITH_ACTION_BAR }}>
        {/* Form fields */}
      </ScrollView>
      
      <ActionBottomBar
        onAction={handleCreate}
        onCancel={() => router.back()}
      />
    </ScreenContainer>
  );
};

Chat Screen (Custom Header)

Some screens need custom headers for unique requirements:
const ChatScreen = () => {
  return (
    <ScreenContainer variant="tab" keyboardAvoiding>
      {/* Custom chat header with avatar, name, typing indicator */}
      <View style={styles.header}>
        <Pressable onPress={() => router.back()}>
          <Ionicons name="arrow-back" ... />
        </Pressable>
        <View style={styles.headerInfo}>
          <Text>{companion.name}</Text>
          {isTyping && <Text>typing...</Text>}
        </View>
      </View>
      
      <FlatList ... />
      
      {/* Chat input - positioned above tab bar */}
      <View style={styles.inputContainer}>
        <TextInput ... />
      </View>
    </ScreenContainer>
  );
};

Decision Tree: Which Variant?

First: Determine if screen should be inside or outside tabs. See Screen Placement Standard.
Where is this screen placed?
├── Outside tabs (forms, global features)
│   └── ScreenContainer variant="default"
│   └── ScreenHeader variant="close"
│   └── ActionBottomBar (no variant)
└── Inside tabs (browse, detail, contextual)
    └── ScreenContainer variant="tab"
    └── Does it have an ActionBottomBar?
        ├── Yes → ActionBottomBar variant="tab"
        └── No → Just ScreenContainer variant="tab"

Which header variant?
├── Form/modal (dismiss) → variant="close"
├── Detail screen (can go back) → variant="back"
└── Tab root screen → variant="none" (with UserProfileHeader)

Migration Guide

When migrating existing screens:
  1. Replace SafeAreaView with ScreenContainer
  2. Replace custom header View with ScreenHeader
  3. Remove manual useSafeAreaInsets and paddingTop calculations
  4. Remove manual TAB_BAR_HEIGHT calculations (use variant="tab" instead)
  5. Replace inline styles with StyleSheet.create()
  6. Use design tokens from ../design-system/tokens