React Native 横竖屏响应式布局实战:一套组件,少量覆盖样式

133 阅读12分钟

React Native 横竖屏响应式布局实战:一套组件,少量覆盖样式

移动端页面从竖屏切换到横屏时,真正需要处理的通常不是颜色、字体和圆角,而是空间分配方式:原来上下排列的内容可能要改成左右排列,单列卡片可能要变成双列,次要信息也可能需要隐藏。

如果为竖屏和横屏分别复制一套页面,短期看起来直观,长期却很容易出现逻辑不同步、修改遗漏和样式重复。更合适的方式是:保留一套组件和一套基础样式,仅为横屏增加少量布局覆盖。

本文通过一个 React Native 审批工作台案例,完整说明方向检测、样式组织、结构切换、原生配置、测试方法和常见问题。

最终效果与截图 Demo

竖屏保持单列信息流,阅读顺序从上到下;横屏利用额外宽度,把摘要区改为左右布局,搜索与分类放到同一行,审批卡片变为双列。

可以直接打开下面的交互 Demo,通过顶部按钮切换横竖屏。这个 HTML 只负责博客视觉演示,真正可用于 React Native 项目的完整代码在本文第四节。

  • [横竖屏响应式布局]

image.png

image.png

一、先分析设计稿的“变化规则”

拿到横竖屏设计稿后,不要马上逐个抄尺寸。先把不变项和变化项分开。

页面区域竖屏横屏实现方式
顶部栏62px,显示副标题46px,隐藏副标题覆盖高度,条件渲染副标题
摘要卡信息上下排列左侧说明、右侧统计修改 flexDirection 或局部结构
搜索与分类上下两行同一行父容器改为横向 Flex
审批列表单列双列flexWrap 与百分比宽度
卡片内部常规间距更紧凑覆盖 padding、margin、gap
颜色、圆角、阴影不变不变只写在基础样式中

这个分析会直接决定代码结构。大多数变化只需要 Style;只有布局层级真正不同的区域,才需要切换少量 JSX。

二、用真实窗口尺寸判断方向

React Native 提供了 useWindowDimensions()。窗口尺寸变化时,它会自动让组件重新渲染,比模块外部调用一次 Dimensions.get() 更适合响应式页面。

import {useWindowDimensions} from 'react-native';

function ApprovalScreen() {
  const {width, height} = useWindowDimensions();
  const isLandscape = width > height;

  return null;
}

不要只在点击“横屏”按钮时手动切换一个布尔值:

// 不推荐:状态可能和设备真实方向不同步
const [isLandscape, setIsLandscape] = useState(false);

用户可能通过系统旋转、分屏、折叠屏展开或平板窗口缩放改变尺寸。布局应该始终以真实窗口宽高为准。

封装一个可复用 Hook

如果项目中有多个响应式页面,可以集中定义方向和断点:

import {useWindowDimensions} from 'react-native';

export function useResponsiveLayout() {
  const {width, height, scale, fontScale} = useWindowDimensions();
  const shortestSide = Math.min(width, height);

  return {
    width,
    height,
    scale,
    fontScale,
    isLandscape: width > height,
    isPhone: shortestSide < 600,
    isTablet: shortestSide >= 600,
    isCompactLandscape: width > height && width < 700,
  };
}

这里没有把“横屏”等同于“宽屏”。例如小尺寸手机横屏和 iPad 横屏的可用空间仍然不同,因此复杂页面还应该结合宽度断点。

三、使用“基础样式 + 横屏覆盖样式”

基础样式描述组件共同的视觉表现,横屏样式只描述差异:

<View style={[styles.header, isLandscape && styles.headerLandscape]} />
const styles = StyleSheet.create({
  header: {
    height: 62,
    paddingHorizontal: 14,
    flexDirection: 'row',
    alignItems: 'center',
  },

  headerLandscape: {
    height: 46,
    paddingHorizontal: 12,
  },
});

React Native 的样式数组会从左到右合并,后面的属性覆盖前面的同名属性。因此横屏不需要重新声明 flexDirection、alignItems 等未变化的属性。

可以把最终结果理解为:

横屏最终样式 = 基础样式 + 横屏差异样式

四、完整案例:审批工作台页面

下面的代码可以作为一个独立页面放入 React Native 项目。案例包含方向检测、摘要区重排、搜索与分类同行、列表单双列切换。

import React, {useEffect} from 'react';
import {
  SafeAreaView,
  ScrollView,
  StyleSheet,
  Text,
  TextInput,
  TouchableOpacity,
  useWindowDimensions,
  View,
} from 'react-native';
import Orientation from 'react-native-orientation-locker';

const approvals = [
  {
    id: 'QJ260831-0001',
    type: '请假审批',
    title: '年假申请',
    applicant: '张明 · 销售一部',
    meta: '年假 · 2.5 天',
    risk: '跨月请假,需核对项目交付安排',
  },
  {
    id: 'XS260904-0018',
    type: '销售合同',
    title: '华东区域年度销售合同',
    applicant: '李雯 · 销售中心',
    meta: '¥ 86,000 · 毛利 28%',
  },
  {
    id: 'FY260904-0007',
    type: '费用报销',
    title: '客户拜访差旅报销',
    applicant: '周强 · 商务部',
    meta: '¥ 12,600 · 发票齐全',
  },
  {
    id: 'CG260904-0021',
    type: '采购审批',
    title: '办公设备集中采购',
    applicant: '王琳 · 行政部',
    meta: '¥ 30,000 · 三方询价',
  },
];

function Metric({value, label, tone}) {
  return (
    <View
      style={[
        styles.metric,
        tone === 'risk' && styles.metricRisk,
        tone === 'done' && styles.metricDone,
      ]}>
      <Text style={styles.metricValue}>{value}</Text>
      <Text style={styles.metricLabel}>{label}</Text>
    </View>
  );
}

function ApprovalCard({item, isLandscape}) {
  return (
    <TouchableOpacity
      activeOpacity={0.82}
      style={[
        styles.approvalCard,
        item.risk && styles.riskCard,
        isLandscape && styles.approvalCardLandscape,
      ]}>
      <View style={styles.cardTop}>
        <Text style={styles.typeTag}>{item.type}</Text>
        <Text style={styles.cardCode}>{item.id}</Text>
      </View>

      <Text style={styles.cardTitle}>{item.title}</Text>
      <Text style={styles.cardApplicant}>{item.applicant}</Text>

      {item.risk ? (
        <View style={styles.riskNote}>
          <Text style={styles.riskIcon}>!</Text>
          <Text style={styles.riskText}>{item.risk}</Text>
          <Text style={styles.riskState}>待复核</Text>
        </View>
      ) : null}

      <View style={styles.cardBottom}>
        <Text style={styles.cardMeta}>{item.meta}</Text>
        <Text style={styles.chevron}>›</Text>
      </View>
    </TouchableOpacity>
  );
}

export default function ResponsiveApprovalDemoScreen() {
  const {width, height} = useWindowDimensions();
  const isLandscape = width > height;
  const useTwoColumns = isLandscape && width >= 700;

  const toggleOrientation = () => {
    if (isLandscape) {
      Orientation.lockToPortrait();
    } else {
      Orientation.lockToLandscape();
    }
  };

  useEffect(() => {
    return () => Orientation.lockToPortrait();
  }, []);

  return (
    <SafeAreaView style={styles.safeArea}>
      <ScrollView
        style={styles.screen}
        contentContainerStyle={[styles.content, isLandscape && styles.contentLandscape]}>
        <View style={[styles.header, isLandscape && styles.headerLandscape]}>
          <Text style={styles.brand}>审</Text>
          <View style={styles.headerCopy}>
            <Text style={[styles.headerTitle, isLandscape && styles.headerTitleLandscape]}>
              数据助理 · 审批
            </Text>
            {!isLandscape ? <Text style={styles.subtitle}>关键事项集中决策</Text> : null}
          </View>
          <TouchableOpacity style={styles.headerButton} onPress={toggleOrientation}>
            <Text style={styles.headerButtonText}>{isLandscape ? '竖屏' : '横屏'}</Text>
          </TouchableOpacity>
        </View>

        <View style={[styles.hero, isLandscape && styles.heroLandscape]}>
          <View style={[styles.heroContent, isLandscape && styles.heroContentLandscape]}>
            <View style={styles.heroCopy}>
              <View style={styles.heroTop}>
                <Text style={styles.heroTitle}>今日待决策</Text>
                <TouchableOpacity style={styles.batchButton}>
                  <Text style={styles.batchButtonText}>✓ 批量审批</Text>
                </TouchableOpacity>
              </View>
              <Text style={styles.amount}>¥ 128,600</Text>
              <Text style={styles.heroDescription}>4 项待办 · 1 项异常需逐项处理</Text>
            </View>

            <View style={[styles.metrics, isLandscape && styles.metricsLandscape]}>
              <Metric value='4' label='待审批' />
              <Metric value='1' label='异常审批' tone='risk' />
              <Metric value='12' label='已处理' tone='done' />
            </View>
          </View>
        </View>

        <View style={[styles.filters, isLandscape && styles.filtersLandscape]}>
          <View style={[styles.search, isLandscape && styles.searchLandscape]}>
            <Text style={styles.searchIcon}>⌕</Text>
            <TextInput
              style={styles.searchInput}
              placeholder='按申请人/审批单号/客户名称'
              placeholderTextColor='#aaa7b4'
            />
          </View>

          <ScrollView
            horizontal
            showsHorizontalScrollIndicator={false}
            style={styles.categoryScroll}
            contentContainerStyle={styles.categories}>
            {['全部', '销售审批', '售后审批', '财务审批', '行政审批'].map((category, index) => (
              <TouchableOpacity
                key={category}
                style={[styles.category, index === 0 && styles.categoryActive]}>
                <Text style={[styles.categoryText, index === 0 && styles.categoryTextActive]}>
                  {category}
                </Text>
              </TouchableOpacity>
            ))}
          </ScrollView>
        </View>

        <View style={styles.sectionHeading}>
          <Text style={styles.sectionTitle}>全部审批</Text>
          <Text style={styles.sectionMeta}>按风险与时效排序</Text>
        </View>

        <View style={[styles.approvalList, useTwoColumns && styles.approvalListLandscape]}>
          {approvals.map(item => (
            <ApprovalCard key={item.id} item={item} isLandscape={useTwoColumns} />
          ))}
        </View>
      </ScrollView>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  safeArea: {flex: 1, backgroundColor: '#f4f3f8'},
  screen: {flex: 1, backgroundColor: '#f4f3f8'},
  content: {paddingBottom: 28},
  contentLandscape: {paddingBottom: 16},

  header: {
    height: 62,
    paddingHorizontal: 14,
    flexDirection: 'row',
    alignItems: 'center',
    gap: 9,
  },
  headerLandscape: {height: 46, paddingHorizontal: 12},
  brand: {
    width: 31,
    height: 31,
    borderRadius: 10,
    color: '#fff',
    backgroundColor: '#626ee9',
    textAlign: 'center',
    lineHeight: 31,
    fontWeight: '800',
  },
  headerCopy: {flex: 1},
  headerTitle: {fontSize: 16, fontWeight: '700', color: '#252334'},
  headerTitleLandscape: {fontSize: 13},
  subtitle: {marginTop: 2, fontSize: 10, color: '#858296'},
  headerButton: {
    height: 28,
    paddingHorizontal: 8,
    borderRadius: 8,
    backgroundColor: '#fff',
    justifyContent: 'center',
  },
  headerButtonText: {fontSize: 10, color: '#858296', fontWeight: '700'},

  hero: {
    minHeight: 190,
    marginHorizontal: 14,
    paddingHorizontal: 17,
    paddingVertical: 13,
    borderRadius: 20,
    backgroundColor: '#6b65e8',
  },
  heroLandscape: {
    minHeight: 102,
    marginHorizontal: 12,
    paddingHorizontal: 15,
    paddingVertical: 11,
  },
  heroContent: {flex: 1},
  heroContentLandscape: {flexDirection: 'row', gap: 20},
  heroCopy: {flex: 1, minWidth: 0},
  heroTop: {
    minHeight: 32,
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
  },
  heroTitle: {fontSize: 16, color: '#fff', fontWeight: '600'},
  batchButton: {
    minHeight: 30,
    paddingHorizontal: 11,
    borderRadius: 10,
    backgroundColor: '#fff',
    justifyContent: 'center',
  },
  batchButtonText: {fontSize: 10, color: '#574ddc', fontWeight: '800'},
  amount: {marginTop: 3, fontSize: 25, color: '#fff', fontWeight: '800'},
  heroDescription: {marginTop: 2, fontSize: 11, color: 'rgba(255,255,255,.82)'},
  metrics: {flexDirection: 'row', gap: 8, marginTop: 13},
  metricsLandscape: {width: '42%', minWidth: 280, marginTop: 0},
  metric: {
    height: 58,
    flex: 1,
    borderWidth: 1,
    borderColor: 'rgba(255,255,255,.32)',
    borderRadius: 12,
    backgroundColor: 'rgba(255,255,255,.12)',
    alignItems: 'center',
    justifyContent: 'center',
  },
  metricRisk: {backgroundColor: 'rgba(185,35,58,.28)'},
  metricDone: {backgroundColor: 'rgba(25,169,109,.38)'},
  metricValue: {fontSize: 20, color: '#fff', fontWeight: '700'},
  metricLabel: {marginTop: 3, fontSize: 11, color: '#fff'},

  filters: {},
  filtersLandscape: {
    marginHorizontal: 12,
    flexDirection: 'row',
    alignItems: 'center',
    gap: 8,
  },
  search: {
    height: 40,
    marginHorizontal: 14,
    marginTop: 10,
    paddingHorizontal: 14,
    borderRadius: 13,
    backgroundColor: '#fff',
    flexDirection: 'row',
    alignItems: 'center',
  },
  searchLandscape: {width: '40%', marginHorizontal: 0},
  searchIcon: {marginRight: 8, fontSize: 22, color: '#515151'},
  searchInput: {height: 40, flex: 1, paddingVertical: 0, fontSize: 12, color: '#252334'},
  categoryScroll: {minWidth: 0},
  categories: {gap: 7, paddingHorizontal: 14, paddingVertical: 10},
  category: {
    height: 32,
    paddingHorizontal: 11,
    borderRadius: 14,
    backgroundColor: '#fff',
    justifyContent: 'center',
  },
  categoryActive: {backgroundColor: '#edeaff'},
  categoryText: {fontSize: 11, color: '#858296'},
  categoryTextActive: {color: '#626ee9', fontWeight: '700'},

  sectionHeading: {
    marginHorizontal: 16,
    marginBottom: 7,
    flexDirection: 'row',
    alignItems: 'flex-end',
    justifyContent: 'space-between',
  },
  sectionTitle: {fontSize: 14, color: '#252334', fontWeight: '700'},
  sectionMeta: {fontSize: 10, color: '#858296'},
  approvalList: {gap: 9},
  approvalListLandscape: {
    paddingHorizontal: 12,
    flexDirection: 'row',
    flexWrap: 'wrap',
    gap: 8,
  },
  approvalCard: {
    marginHorizontal: 14,
    padding: 13,
    borderWidth: 1,
    borderColor: '#f4f3f8',
    borderRadius: 17,
    backgroundColor: '#fff',
  },
  approvalCardLandscape: {
    width: '49.4%',
    marginHorizontal: 0,
    padding: 10,
  },
  riskCard: {borderColor: 'rgba(245,92,92,.34)'},
  cardTop: {flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between'},
  typeTag: {
    paddingHorizontal: 8,
    paddingVertical: 5,
    borderRadius: 9,
    color: '#626ee9',
    backgroundColor: '#eeeaff',
    fontSize: 10,
  },
  cardCode: {fontSize: 9, color: '#858296'},
  cardTitle: {marginTop: 8, fontSize: 15, color: '#252334', fontWeight: '700'},
  cardApplicant: {marginTop: 4, fontSize: 10, color: '#858296'},
  riskNote: {
    marginTop: 9,
    padding: 8,
    borderRadius: 10,
    backgroundColor: '#fff0ee',
    flexDirection: 'row',
    alignItems: 'center',
    gap: 7,
  },
  riskIcon: {
    width: 17,
    height: 17,
    borderRadius: 9,
    color: '#fff',
    backgroundColor: '#f55c5c',
    textAlign: 'center',
    lineHeight: 17,
    fontWeight: '800',
  },
  riskText: {flex: 1, fontSize: 10, color: '#c44247'},
  riskState: {fontSize: 9, color: '#c44247', fontWeight: '700'},
  cardBottom: {
    marginTop: 10,
    paddingTop: 9,
    borderTopWidth: 1,
    borderTopColor: '#ebe9f0',
    flexDirection: 'row',
    alignItems: 'center',
  },
  cardMeta: {flex: 1, fontSize: 10, color: '#2ac265'},
  chevron: {fontSize: 20, color: '#aaa5b4'},
});

五、横屏按钮只负责旋转,布局仍然看真实尺寸

如果产品需要“横屏/竖屏”按钮,可以使用 react-native-orientation-locker:

import React, {useEffect} from 'react';
import {Pressable, Text, useWindowDimensions} from 'react-native';
import Orientation from 'react-native-orientation-locker';

function OrientationButton() {
  const {width, height} = useWindowDimensions();
  const isLandscape = width > height;

  const toggleOrientation = () => {
    if (isLandscape) {
      Orientation.lockToPortrait();
    } else {
      Orientation.lockToLandscape();
    }
  };

  useEffect(() => {
    return () => Orientation.lockToPortrait();
  }, []);

  return (
    <Pressable onPress={toggleOrientation}>
      <Text>{isLandscape ? '竖屏' : '横屏'}</Text>
    </Pressable>
  );
}

这里要区分两个职责:

  • Orientation.lockToLandscape():向系统请求旋转。
  • useWindowDimensions():读取旋转完成后的真实窗口尺寸,决定页面布局。

不要在调用 lockToLandscape() 后直接把本地状态改成横屏。系统旋转可能失败、延迟,或者被设备策略限制。

六、Android 与 iOS 原生配置

只安装 JavaScript 依赖并不一定够,方向库通常还需要原生配置。

Android

在 AndroidManifest.xml 中确保主 Activity 声明尺寸和方向变化:

<activity
    android:name=".MainActivity"
    android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
    android:windowSoftInputMode="adjustResize" />

在 MainActivity.java 转发方向变化:

@Override
public void onConfigurationChanged(Configuration newConfig) {
  super.onConfigurationChanged(newConfig);
  Intent intent = new Intent("onConfigurationChanged");
  intent.putExtra("newConfig", newConfig);
  sendBroadcast(intent);
}

在 MainApplication.java 注册生命周期监听:

@Override
public void onCreate() {
  super.onCreate();
  registerActivityLifecycleCallbacks(
      OrientationActivityLifecycle.getInstance()
  );
}

iOS

Info.plist 需要包含允许的方向:

<key>UISupportedInterfaceOrientations</key>
<array>
  <string>UIInterfaceOrientationPortrait</string>
  <string>UIInterfaceOrientationLandscapeLeft</string>
  <string>UIInterfaceOrientationLandscapeRight</string>
</array>

AppDelegate.mm 根据方向库返回支持方向:

#import "Orientation.h"

- (UIInterfaceOrientationMask)application:(UIApplication *)application
        supportedInterfaceOrientationsForWindow:(UIWindow *)window {
  return [Orientation getOrientation];
}

原生配置应以项目实际使用的库版本文档为准。

七、什么时候只切 Style,什么时候切 JSX?

只切 Style

下面这些变化通常不需要维护两套 JSX:

  • 宽度、高度和内外边距变化。
  • 单列变双列。
  • 上下排列变左右排列。
  • 文字对齐、按钮尺寸变化。
  • 卡片内部变得更紧凑。
<View style={[styles.filters, isLandscape && styles.filtersLandscape]}>
  <Search />
  <Categories />
</View>

局部切 JSX

当内容顺序或层级关系真的发生改变时,可以局部切换结构:

{
  isLandscape ? (
    <View style={styles.heroRow}>
      <HeroCopy />
      <Metrics />
    </View>
  ) : (
    <>
      <HeroCopy />
      <Metrics />
    </>
  );
}

即使如此,也应该复用 HeroCopy 和 Metrics,不要把内容复制两遍。

八、把响应式尺寸集中成布局变量

页面复杂后,可以将高频尺寸集中计算:

function createLayout(isLandscape) {
  return {
    pagePadding: isLandscape ? 12 : 14,
    sectionGap: isLandscape ? 8 : 12,
    cardPadding: isLandscape ? 10 : 14,
    headerHeight: isLandscape ? 46 : 62,
    columnCount: isLandscape ? 2 : 1,
  };
}

function Screen() {
  const {width, height} = useWindowDimensions();
  const isLandscape = width > height;
  const layout = useMemo(() => createLayout(isLandscape), [isLandscape]);

  return <View style={{paddingHorizontal: layout.pagePadding}} />;
}

这样设计师要求“横屏整体再紧凑一点”时,只需要调整少数变量,不用在整个文件里寻找散落的数字。

不要为了几个简单数值过度使用 useMemo();只有布局对象较复杂,或者会作为 props 传给经过 memo 的子组件时才有明显意义。

九、安全区、大字号和键盘也要一起验证

横屏适配不能只看宽度。

安全区

刘海屏横屏后左右可能产生较大的安全区。业务页面推荐使用 react-native-safe-area-context:

import {useSafeAreaInsets} from 'react-native-safe-area-context';

function Screen() {
  const insets = useSafeAreaInsets();

  return (
    <View
      style={{
        paddingLeft: Math.max(insets.left, 12),
        paddingRight: Math.max(insets.right, 12),
      }}
    />
  );
}

大字号

不要通过固定卡片高度强行限制内容。优先使用 minHeight,并允许文字换行或内容滚动。

card: {
  minHeight: 120,
  // 避免 height: 120
}

键盘

横屏高度较小,输入框获得焦点后更容易被键盘遮挡。表单页面需要配合 KeyboardAvoidingView,并确认 Android 的 windowSoftInputMode 配置。

十、测试方向判断与关键样式

响应式布局最好至少覆盖竖屏和横屏两个渲染用例。

import React from 'react';
import renderer from 'react-test-renderer';
import * as ReactNative from 'react-native';
import ResponsiveApprovalDemoScreen from '../ResponsiveApprovalDemoScreen';

describe('ResponsiveApprovalDemoScreen', () => {
  afterEach(() => jest.restoreAllMocks());

  it('竖屏使用单列布局', () => {
    jest.spyOn(ReactNative, 'useWindowDimensions').mockReturnValue({
      width: 390,
      height: 844,
      scale: 3,
      fontScale: 1,
    });

    const tree = renderer.create(<ResponsiveApprovalDemoScreen />).toJSON();
    expect(tree).toBeTruthy();
  });

  it('宽屏横屏使用双列布局', () => {
    jest.spyOn(ReactNative, 'useWindowDimensions').mockReturnValue({
      width: 844,
      height: 390,
      scale: 3,
      fontScale: 1,
    });

    const tree = renderer.create(<ResponsiveApprovalDemoScreen />).toJSON();
    expect(tree).toBeTruthy();
  });
});

完整项目还可以给关键容器增加 testID,断言合并后的 Style 是否包含双列宽度、横向排列等关键属性。

十一、推荐的设计稿还原顺序

不要从字体和 1px 间距开始。更高效的顺序是:

  1. 确认页面安全区与可滚动区域。
  2. 调整 Header、摘要区等大模块高度。
  3. 确认大模块是上下还是左右排列。
  4. 确认列表列数和卡片宽度。
  5. 调整卡片内部的信息顺序与按钮位置。
  6. 最后处理字号、图标、阴影和细小间距。

外层布局还没稳定时,过早调整内部细节通常会返工。

十二、常见问题

1. 使用模块级 Dimensions.get()

// 不推荐:文件加载时只读取一次
const {width, height} = Dimensions.get('window');

旋转后变量不会自动更新。页面组件里优先使用 useWindowDimensions()。

2. 横屏后所有元素按比例放大

横屏增加的是宽度,不代表按钮、字号和卡片都要等比例放大。移动端横屏高度更紧张,通常应该提高信息密度。

3. 主体布局大量使用绝对定位

绝对定位适合背景装饰、角标和悬浮按钮,不适合页面主体。不同屏幕和大字号模式下很容易重叠。

4. 所有横屏都强制双列

小屏手机横屏的安全区和侧边手势区域可能占用大量空间。建议加入宽度判断:

const useTwoColumns = isLandscape && width >= 700;

5. 为横竖屏复制整套页面

业务状态、点击事件和接口数据很容易逐渐不一致。优先共享组件和数据,只在布局真正不同的位置切换容器结构。

总结

React Native 横竖屏适配可以归纳为四层:

  1. 使用 useWindowDimensions() 获取真实窗口状态。
  2. 使用基础样式保存共同视觉表现。
  3. 使用横屏覆盖样式调整位置、尺寸、间距和列数。
  4. 只在层级关系确实变化时局部切换 JSX。

核心目标不是让横屏页面简单地“变宽”,而是让它重新分配空间,同时继续复用同一份业务逻辑和组件。