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

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

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

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

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

最终效果与截图 Demo

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

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

  • 横竖屏响应式布局

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

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

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

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

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

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

jsx 复制代码
import {useWindowDimensions} from 'react-native';

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

  return null;
}

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

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

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

封装一个可复用 Hook

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

jsx 复制代码
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 横屏的可用空间仍然不同,因此复杂页面还应该结合宽度断点。

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

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

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

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

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

可以把最终结果理解为:

text 复制代码
横屏最终样式 = 基础样式 + 横屏差异样式

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

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

jsx 复制代码
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

jsx 复制代码
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 声明尺寸和方向变化:

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

MainActivity.java 转发方向变化:

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

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

java 复制代码
@Override
public void onCreate() {
  super.onCreate();
  registerActivityLifecycleCallbacks(
      OrientationActivityLifecycle.getInstance()
  );
}

iOS

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

xml 复制代码
<key>UISupportedInterfaceOrientations</key>
<array>
  <string>UIInterfaceOrientationPortrait</string>
  <string>UIInterfaceOrientationLandscapeLeft</string>
  <string>UIInterfaceOrientationLandscapeRight</string>
</array>

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

objc 复制代码
#import "Orientation.h"

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

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

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

只切 Style

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

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

局部切 JSX

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

jsx 复制代码
{
  isLandscape ? (
    <View style={styles.heroRow}>
      <HeroCopy />
      <Metrics />
    </View>
  ) : (
    <>
      <HeroCopy />
      <Metrics />
    </>
  );
}

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

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

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

jsx 复制代码
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

jsx 复制代码
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,并允许文字换行或内容滚动。

jsx 复制代码
card: {
  minHeight: 120,
  // 避免 height: 120
}

键盘

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

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

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

jsx 复制代码
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()

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

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

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

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

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

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

4. 所有横屏都强制双列

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

jsx 复制代码
const useTwoColumns = isLandscape && width >= 700;

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

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

总结

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

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

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

相关推荐
杉氧3 小时前
打破边界(一):实战编写 Android/iOS 原生模块 (Native Modules)
android·react native·前端框架
武当王丶也3 小时前
React Native 本地草稿设计:恢复、过期与版本迁移
react native
光影少年1 天前
react navite实现全局弹窗、Toast 组件
前端·react native·react.js
武当王丶也1 天前
React Native 扫码事件调度实战:用监听栈解决页面、弹窗与 Sheet 冲突
react native
光影少年1 天前
react navite封装一个RN 通用按钮组件
前端·javascript·react native·react.js·前端框架
杉氧2 天前
页面栈与路由:React Navigation 与 Expo Router 深度实践
android·前端·react native
光影少年2 天前
react navite高频手写/实操题
前端·javascript·react native·react.js·前端框架
杉氧3 天前
状态管理变迁史:为什么我们放弃了 Redux 选择 Zustand?
android·前端·react native