ai-coding
2026-08-07 62ba21653bbf7afa162b5c5797d11c65f8a8725d
feat: scrollView兼容ios弹窗问题 (甄雪君)
1个文件已添加
5个文件已修改
177 ■■■■■ 已修改文件
src/robotaxi/pages/begin-route/components/calling-car-modules/fragrance-control/index.tsx 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/music-control/index.tsx 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/temperature-control.tsx 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/types.ts 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car.tsx 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/css-folder/music-control.less 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/fragrance-control/index.tsx
@@ -7,6 +7,7 @@
import { getOrderId } from '../../../../../utils/order';
import { TripStatus } from '../../../../../constants/trip-status';
import useBottomPopupAnimation from '../../hooks/use-bottom-popup-animation';
import type { ControlNodes } from '../types';
import AcSwitch from '../ac-switch';
import '../../css-folder/fragrance-control.less';
@@ -78,7 +79,13 @@
    return scents.length ? scents[0].channelId : '';
}
export default function FragranceControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props) {
/**
 * 香氛控制 Hook:返回「入口卡片」与「控制弹窗」两个可分别挂载的节点。
 * 拆分原因:卡片要放进横向滑动的 ScrollView,而弹窗是 position: fixed 全屏浮层;
 * 只要弹窗位于 scroll-view 子树内,iOS 会把它降级为相对滚动容器定位并裁切(Android 表现正常)。
 * 因此调用方必须把 card 放进 ScrollView、把 popup 放在 ScrollView 之外。
 */
export function useFragranceControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props): ControlNodes {
    // 车端真实状态
    const [switchOn, setSwitchOn] = useState(false);
    const [scents, setScents] = useState<FragranceScent[]>([]);
@@ -213,13 +220,13 @@
    // 连环单或非接驾态不展示香氛卡片
    if (tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) {
        return null;
        return { card: null, popup: null };
    }
    // 香氛卡片背景随开关状态切换(复用 tf-card 视觉)
    const fragranceCardStateClass = switchOn ? 'is-open' : 'is-close';
    return (
    const card = (
        <View className='fragrance-control'>
            {/* 香氛入口卡片(复用 tf-card 视觉):点击打开面板 */}
            <View
@@ -241,10 +248,13 @@
                    </View>
                </View>
            </View>
        </View>
    );
            {/* 控制面板(底部弹窗) */}
            {shouldRender && (
                <View className={`fragrance-popup ${panelVisible ? 'show' : ''}`} catchMove>
    // 控制面板(底部弹窗):与卡片分开挂载,必须渲染在 ScrollView 之外,否则 iOS 上会被滚动容器裁切
    const popup = shouldRender ? (
        <View className='fragrance-control'>
            <View className={`fragrance-popup ${panelVisible ? 'show' : ''}`} catchMove>
                    <View className='popup-mask' onClick={closePanel}></View>
                    <View className='popup-content' animation={animationData}>
                        <View className='popup-header'>
@@ -276,8 +286,15 @@
                            )}
                        </View>
                    </View>
                </View>
            )}
            </View>
        </View>
    );
    ) : null;
    return { card, popup };
}
/** 兼容旧用法:卡片与弹窗一起渲染(不在 ScrollView 内的场景可直接使用本组件) */
export default function FragranceControl(props: Props) {
    const { card, popup } = useFragranceControl(props);
    return <>{card}{popup}</>;
}
src/robotaxi/pages/begin-route/components/calling-car-modules/music-control/index.tsx
@@ -5,6 +5,7 @@
import { getOrderId } from '../../../../../utils/order';
import { TripStatus } from '../../../../../constants/trip-status';
import useBottomPopupAnimation from '../../hooks/use-bottom-popup-animation';
import type { ControlNodes } from '../types';
import AcSwitch from '../ac-switch';
import { showToast } from '../../../../../components/toast';
import '../../css-folder/music-control.less';
@@ -58,7 +59,13 @@
    return genres.length ? genres[0].id : '';
}
export default function MusicControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props) {
/**
 * 音乐控制 Hook:返回「入口卡片」与「控制弹窗」两个可分别挂载的节点。
 * 拆分原因:卡片要放进横向滑动的 ScrollView,而弹窗是 position: fixed 全屏浮层;
 * 只要弹窗位于 scroll-view 子树内,iOS 会把它降级为相对滚动容器定位并裁切(Android 表现正常)。
 * 因此调用方必须把 card 放进 ScrollView、把 popup 放在 ScrollView 之外。
 */
export function useMusicControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props): ControlNodes {
    // 车端真实开关状态
    const [switchOn, setSwitchOn] = useState(false);
    // 后台配置曲风列表
@@ -197,13 +204,13 @@
    // 连环单或非接驾态不展示音乐卡片
    if (tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) {
        return null;
        return { card: null, popup: null };
    }
    // 音乐卡片背景随开关状态切换
    const musicCardStateClass = switchOn ? 'is-open' : 'is-close';
    return (
    const card = (
        <View className='music-control'>
            {/* 音乐入口卡片(复用 tf-card 视觉):点击打开面板 */}
            <View
@@ -237,10 +244,13 @@
                    </View>
                </View>
            </View>
        </View>
    );
            {/* 控制面板(底部弹窗) */}
            {shouldRender && (
                <View className={`music-popup ${panelVisible ? 'show' : ''}`} catchMove>
    // 控制面板(底部弹窗):与卡片分开挂载,必须渲染在 ScrollView 之外,否则 iOS 上会被滚动容器裁切
    const popup = shouldRender ? (
        <View className='music-control'>
            <View className={`music-popup ${panelVisible ? 'show' : ''}`} catchMove>
                    <View className='popup-mask' onClick={closePanel}></View>
                    <View className='popup-content' animation={animationData}>
                        <View className='popup-header'>
@@ -280,8 +290,15 @@
                            <AcSwitch checked={switchOn} loading={switchLoading} onChange={handleToggleSwitch} />
                        </View>
                    </View>
                </View>
            )}
            </View>
        </View>
    );
    ) : null;
    return { card, popup };
}
/** 兼容旧用法:卡片与弹窗一起渲染(不在 ScrollView 内的场景可直接使用本组件) */
export default function MusicControl(props: Props) {
    const { card, popup } = useMusicControl(props);
    return <>{card}{popup}</>;
}
src/robotaxi/pages/begin-route/components/calling-car-modules/temperature-control.tsx
@@ -14,6 +14,7 @@
import '../css-folder/temperature-control.less';
import { getOrderId } from '../../../../utils/order';
import { TripStatus } from '../../../../constants/trip-status';
import type { ControlNodes } from './types';
import { MODIFY_DESTINATION_SKIP_AC_QUERY_ON_RETURN_KEY } from '../../../../constants/modify-destination';
import { getAcStorage, saveAcStorage } from './ac-storage';
import useMinimumSpinLoading from './use-minimum-spin-loading';
@@ -41,7 +42,13 @@
    hasPreviousOrder?: boolean;
};
export default function TemperatureControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props) {
/**
 * 空调控制 Hook:返回「入口卡片」与「温度调节弹窗」两个可分别挂载的节点。
 * 拆分原因:卡片要放进横向滑动的 ScrollView,而弹窗是 position: fixed 全屏浮层;
 * 只要弹窗位于 scroll-view 子树内,iOS 会把它降级为相对滚动容器定位并裁切(Android 表现正常)。
 * 因此调用方必须把 card 放进 ScrollView、把 popup 放在 ScrollView 之外。
 */
export function useAcControl({ tripStatus, orderId, pageDidShowVersion = 0, hasPreviousOrder = false }: Props): ControlNodes {
    const [tempStatus, setTempStatus] = useState<number | null>(null);
    const [acSwitchOn, setAcSwitchOn] = useState(false);
    const [acStatusLoaded, setAcStatusLoaded] = useState(false);
@@ -451,10 +458,10 @@
    // 连环单(车还在执行上一单)时不展示空调卡片
    if (tripStatus !== TripStatus.VehicleApproaching || hasPreviousOrder) {
        return null;
        return { card: null, popup: null };
    }
    return (
    const card = (
        <View className='ac-control'>
            {/* 空调入口卡片(复用 tf-card 视觉):点击打开温度调节面板;右上开关直接切空调 */}
            <View
@@ -481,10 +488,14 @@
                    </View>
                </View>
            </View>
        </View>
    );
            {/* 温度调节面板(底部弹窗,fixed 定位覆盖全屏) */}
            {shouldRender && (
                <View className={`ac-popup ${panelVisible ? 'show' : ''}`} catchMove>
    // 温度调节面板(底部弹窗,fixed 定位覆盖全屏):与卡片分开挂载,
    // 必须渲染在 ScrollView 之外,否则 iOS 上会被滚动容器裁切
    const popup = shouldRender ? (
        <View className='ac-control'>
            <View className={`ac-popup ${panelVisible ? 'show' : ''}`} catchMove>
                    <View className='popup-mask' onClick={closePanel}></View>
                    <View className='popup-content' animation={animationData}>
                        {/* 座椅通风/加热区(顶部满宽,关闭按钮浮于右上角) */}
@@ -536,8 +547,15 @@
                            </View>
                        </View>
                    </View>
                </View>
            )}
            </View>
        </View>
    );
    ) : null;
    return { card, popup };
}
/** 兼容旧用法:卡片与弹窗一起渲染(不在 ScrollView 内的场景可直接使用本组件) */
export default function TemperatureControl(props: Props) {
    const { card, popup } = useAcControl(props);
    return <>{card}{popup}</>;
}
src/robotaxi/pages/begin-route/components/calling-car-modules/types.ts
New file
@@ -0,0 +1,16 @@
import type { ReactNode } from 'react';
/**
 * 车控组件(空调 / 音乐 / 香氛)拆分后的两个挂载点。
 *
 * 卡片需要放进横向滑动的 ScrollView,而弹窗是 position: fixed 的全屏浮层。
 * iOS(WKWebView)下 scroll-view 的滚动容器会成为 fixed 子元素的包含块,
 * 导致弹窗被限制在滚动区域内并被裁切(Android 正常),因此两者必须分开挂载:
 * card 放进 ScrollView,popup 放在 ScrollView 之外。
 */
export type ControlNodes = {
    /** 入口卡片节点:放进横向滑动容器 */
    card: ReactNode;
    /** 控制弹窗节点:必须挂在 ScrollView 之外 */
    popup: ReactNode;
};
src/robotaxi/pages/begin-route/components/calling-car.tsx
@@ -1,5 +1,5 @@
import { View, Image, ScrollView } from "@tarojs/components";
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import './css-folder/calling-car.less'
import { formatRemainingDurationDisplay } from '../../../utils/format';
import { safeParse, formatTripDistance } from '../../../../utils/Robotaxi-public-method';
@@ -7,9 +7,9 @@
import { BLUETOOTH_ICON_OFF } from '../hooks/use-bluetooth-access'; // 蓝牙未开/未授权图标
import TripStatusHeader from './calling-car-modules/trip-status-header'; // 行程状态头
import VehicleInfoCard from './calling-car-modules/vehicle-info-card'; // 车辆信息卡片
import TemperatureControl from './calling-car-modules/temperature-control'; // 空调温控(含温度调节面板 + 座椅通风加热)
import FragranceControl from './calling-car-modules/fragrance-control'; // 车载香氛控制
import MusicControl from './calling-car-modules/music-control'; // 车载音乐曲风控制
import { useAcControl } from './calling-car-modules/temperature-control'; // 空调温控(含温度调节面板 + 座椅通风加热)
import { useFragranceControl } from './calling-car-modules/fragrance-control'; // 车载香氛控制
import { useMusicControl } from './calling-car-modules/music-control'; // 车载音乐曲风控制
import TripActionBar from './calling-car-modules/trip-action-bar'; // 行程操作栏
import DoorTrunkControl from './calling-car-modules/door-trunk-control'; // 门箱控制
import FareDetailCard from './calling-car-modules/fare-detail-card'; // 费用明细卡片
@@ -174,6 +174,25 @@
    /** 是否为“到达终点/待支付”状态(图一、图二) */
    const isTripFinished = tripStatus === TripStatus.TripFinished;
    const isCallingCar = tripStatus === TripStatus.CallingCar;
    /**
     * 车控(空调 / 音乐 / 香氛):卡片与弹窗分开挂载。
     * 卡片放进横向滑动的 ScrollView;弹窗是 fixed 全屏浮层,必须挂在 ScrollView 之外,
     * 否则 iOS(WKWebView)下滚动容器会成为 fixed 的包含块,弹窗被限制在卡片滑动区内。
     * 三个 hook 内部均按 tripStatus / hasPreviousOrder 守卫,非接驾态不发请求、返回空节点。
     */
    const acControl = useAcControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder });
    const musicControl = useMusicControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder });
    const fragranceControl = useFragranceControl({ tripStatus, orderId, pageDidShowVersion, hasPreviousOrder });
    /**
     * 车控卡片当前横向滚动位置。
     * 弹窗开合与其弹出动画会驱动本组件重渲染,weapp 的 scroll-view 在重渲染 / 重建后会回到
     * 初始位置(最左),表现为「弹窗一出现卡片就滑回去了」。这里用 ref 记录实时位置并回传
     * scroll-left,使重渲染后停留在原处;用 ref 而非 state 是为了滚动过程中不触发额外渲染。
     */
    const cardScrollLeftRef = useRef(0);
    const handleCardScroll = (e: any) => {
        cardScrollLeftRef.current = e?.detail?.scrollLeft ?? 0;
    };
    /** 费用明细是否展开(拦截 FareDetailCard 的回调本地记录,用于切换背景与卡片布局) */
    const [detailExpanded, setDetailExpanded] = useState(false);
    /** 大号里程文案:取全程里程,单位参考 formatTripDistance(米→M,公里→KM) */
@@ -276,14 +295,21 @@
            {
                [TripStatus.VehicleApproaching, TripStatus.VehicleArrivedPickup, TripStatus.InService].includes(tripStatus) &&
                <View className='car-card'>
                    {/* 车控卡片横向滑动(仅接驾态展示):weapp 中普通 View 的 overflow 滚动无法响应拖动,必须用 ScrollView */}
                    {/* 车控卡片横向滑动(仅接驾态展示):weapp 中普通 View 的 overflow 滚动无法响应拖动,必须用 ScrollView。
                        注意:这里只放卡片,三个控制弹窗在下方 ScrollView 之外单独挂载(iOS fixed 会被滚动容器裁切) */}
                    {
                        TripStatus.VehicleApproaching === tripStatus &&
                        <ScrollView className='temp-fragrance-scroll' scrollX showScrollbar={false}>
                        <ScrollView
                          className='temp-fragrance-scroll'
                          scrollX
                          showScrollbar={false}
                          scrollLeft={cardScrollLeftRef.current}
                          onScroll={handleCardScroll}
                        >
                          <View className='temp-fragrance-control'>
                            <TemperatureControl tripStatus={tripStatus} orderId={orderId} pageDidShowVersion={pageDidShowVersion} hasPreviousOrder={hasPreviousOrder} />
                            <MusicControl tripStatus={tripStatus} orderId={orderId} pageDidShowVersion={pageDidShowVersion} hasPreviousOrder={hasPreviousOrder} />
                            <FragranceControl tripStatus={tripStatus} orderId={orderId} pageDidShowVersion={pageDidShowVersion} hasPreviousOrder={hasPreviousOrder} />
                            {acControl.card}
                            {musicControl.card}
                            {fragranceControl.card}
                          </View>
                        </ScrollView>
                    }
@@ -310,6 +336,15 @@
                    />
                </View>
            }
            {/* 车控弹窗(空调 / 音乐 / 香氛):必须挂在横向滑动 ScrollView 之外。
                iOS(WKWebView)下 scroll-view 的滚动容器会成为 fixed 子元素的包含块,
                弹窗若留在 ScrollView 内会被限制、裁切在卡片滑动区里(Android 无此问题)。
                非接驾态时三个 hook 均返回 null,无额外渲染开销。 */}
            {acControl.popup}
            {musicControl.popup}
            {fragranceControl.popup}
            {
                [TripStatus.TripFinished, TripStatus.PaymentSuccess].includes(tripStatus) &&
                <View className='car-card fare-box-card'>
src/robotaxi/pages/begin-route/components/css-folder/music-control.less
@@ -55,7 +55,7 @@
  /* 曲风名称,大小类似温度/香氛名 */
  .tf-card-music-name {
    font-size: 40px;
    font-weight: normal;
    font-weight: 500;
    line-height: 64px;
    color: @black-text-main;
    overflow: hidden;