--- localeCode: zh-CN order: 32 category: 输入类 title: Transfer 穿梭框 icon: doc-transfer width: 60% dir: column brief: 一个更直观高效的多选选择器,可以露出更多选项的信息,支持搜索功能,缺点是占据更多空间 --- ## 代码演示 ### 如何引入 ```jsx import import { Transfer } from '@douyinfe/semi-ui'; ``` ### 基本使用 数据项需传入 value、label、key ```jsx live=true dir="column" import React from 'react'; import { Transfer } from '@douyinfe/semi-ui'; () => { const data = Array.from({ length: 100 }, (v, i) => { return { label: `选项名称 ${i}`, value: i, disabled: false, key: i, }; }); return ( console.log(values, items)} /> ); }; ``` ### 分组 将 type 设为 `groupList` 分组的 dataSource,一级子元素必须拥有 title 以及 children 属性,结构参考 暂不支持多层嵌套 ```jsx live=true dir="column" import React from 'react'; import { Transfer } from '@douyinfe/semi-ui'; () => { const dataWithGroup = [ { title: '类别A', children: [ { label: 'A-1', value: 1, disabled: false, key: 1 }, { label: 'A-2', value: 2, disabled: false, key: 2 }, { label: 'A-3', value: 3, disabled: false, key: 3 }, ], }, { title: '类别B', children: [ { label: 'B-1', value: 4, disabled: false, key: 4 }, { label: 'B-2', value: 5, disabled: false, key: 5 }, { label: 'B-3(disabled)', value: 6, disabled: true, key: 6 }, ], }, { title: '类别C', children: [ { label: 'C-1', value: 7, disabled: false, key: 7 }, { label: 'C-2', value: 8, disabled: false, key: 8 }, { label: 'C-3', value: 9, disabled: false, key: 9 }, { label: 'C-4', value: 10, disabled: false, key: 10 }, { label: 'C-5', value: 11, disabled: false, key: 11 }, { label: 'C-6', value: 12, disabled: false, key: 12 }, { label: 'C-7', value: 13, disabled: false, key: 13 }, ], }, ]; return ( console.log(values, items)} /> ); }; ``` ### 自定义筛选逻辑,自定义选项数据渲染 使用`filter`自定义搜索逻辑,返回 true 时表示当前项符合筛选规则,保留当前项在列表中的显示,返回 false 则表示不符合,当前项会被隐藏 使用`renderSourceItem`,你可以自定义左侧每一条源数据的渲染结构 使用`renderSelectedItem` 你可以自定义右侧每一条已选项的渲染结构 ```jsx live=true dir="column" import React from 'react'; import { Transfer, Checkbox, Avatar } from '@douyinfe/semi-ui'; import { IconClose } from '@douyinfe/semi-icons'; () => { const renderSourceItem = item => { return (
{ item.onChange(); }} key={item.label} checked={item.checked} style={{ height: 52 }} > {item.abbr}
{item.label}
{item.value}
); }; const renderSelectedItem = item => { return (
{item.abbr}
{item.label}
{item.value}
); }; const customFilter = (sugInput, item) => { return item.value.includes(sugInput) || item.label.includes(sugInput); }; const data = [ { label: '夏可漫', value: 'xiakeman@example.com', abbr: '夏', color: 'amber', area: 'US', key: 1 }, { label: '申悦', value: 'shenyue@example.com', abbr: '申', color: 'indigo', area: 'UK', key: 2 }, { label: '文嘉茂', value: 'wenjiamao@example.com', abbr: '文', color: 'cyan', area: 'HK', key: 3 }, { label: '曲晨一', value: 'quchenyi@example.com', abbr: '曲', color: 'blue', area: 'India', key: 4 }, { label: '曲晨二', value: 'quchener@example.com', abbr: '二', color: 'blue', area: 'India', key: 5 }, { label: '曲晨三', value: 'quchensan@example.com', abbr: '三', color: 'blue', area: 'India', key: 6 }, ]; return ( console.log(values, items)} /> ); }; ``` ```css .components-transfer-demo-selected-item { .semi-icon-close { visibility: hidden; color: var(--semi-color-tertiary); } &:hover { .semi-icon-close { visibility: visible; } } } .components-transfer-demo-selected-item, .components-transfer-demo-source-item { height: 52px; box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; &:hover { background-color: var(--semi-color-fill-0); } .info { margin-left: 8px; flex-grow: 1; } .name { font-size: 14px; line-height: 20px; } .email { font-size: 12px; line-height: 16px; color: var(--semi-color-text-2); } } ``` ### 禁用 ```jsx live=true dir="column" import React from 'react'; import { Transfer } from '@douyinfe/semi-ui'; () => { const data = Array.from({ length: 20 }, (v, i) => { return { label: `选项名称 ${i}`, value: i, disabled: false, key: i, }; }); return ( console.log(values, items)} /> ); }; ``` ### 拖拽排序 将 `draggable`设为 true,开启拖拽排序功能。v1.11.0 后支持 ```jsx live=true dir="column" import React from 'react'; import { Transfer } from '@douyinfe/semi-ui'; () => { const data = Array.from({ length: 30 }, (v, i) => { return { label: `选项名称 ${i}`, value: i, disabled: false, key: i, }; }); return ( console.log(values, items)} /> ); }; ``` ### 拖拽 + 自定义已选项渲染 将 `draggable`设为 true,开启拖拽排序功能;使用 `renderSelectedItem` 自定义右侧已选项渲染; 你可以将触发器定义为任意你想要的ReactNode,并且添加样式。将拖拽触发器,使用 `sortableHandle` 进行包裹即可(sortableHandle于 v 1.22.0 后提供), ```jsx live=true dir="column" import React from 'react'; import { Transfer, Checkbox, Avatar } from '@douyinfe/semi-ui'; import { IconHandle, IconClose } from '@douyinfe/semi-icons'; () => { const renderSourceItem = item => { return (
{ item.onChange(); }} key={item.label} checked={item.checked} style={{ height: 52 }} > {item.abbr}
{item.label}
{item.value}
); }; const renderSelectedItem = item => { const { sortableHandle } = item; const DragHandle = sortableHandle(() => ); return (
{item.abbr}
{item.label}
{item.value}
); }; const customFilter = (sugInput, item) => { return item.value.includes(sugInput) || item.label.includes(sugInput); }; const data = [ { label: '夏可漫', value: 'xiakeman@example.com', abbr: '夏', color: 'amber', area: 'US', key: 1 }, { label: '申悦', value: 'shenyue@example.com', abbr: '申', color: 'indigo', area: 'UK', key: 2 }, { label: '文嘉茂', value: 'wenjiamao@example.com', abbr: '文', color: 'cyan', area: 'HK', key: 3 }, { label: '曲晨一', value: 'quchenyi@example.com', abbr: '曲', color: 'blue', area: 'India', key: 4 }, { label: '曲晨二', value: 'quchener@example.com', abbr: '二', color: 'blue', area: 'India', key: 5 }, { label: '曲晨三', value: 'quchensan@example.com', abbr: '三', color: 'blue', area: 'India', key: 6 }, ]; return ( console.log(values, items)} /> ); }; ``` ### 完全自定义渲染 Semi 提供了 `renderSourcePanel`、`renderSelectedPanel` 入参,允许你完全自定义左右侧两个面板的渲染结构 通过该功能,你可以直接复用 Transfer 内部的逻辑能力,实现高度自定义样式结构的`Transfer`组件 `renderSourcePanel: (sourcePanelProps: SourcePanelProps) => ReactNode` `SourcePanelProps`包含以下参数,你可以从中获取数据来渲染出你的 Panel 结构 ```ts interface SourcePanelProps { value: Array; // 所有选中项的key loading: boolean; // 是否加载中 noMatch: boolean; // 是否没有符合当前搜索值匹配的项 filterData: Array // 匹配当前搜索值的项 sourceData: Array; // 所有项 allChecked: boolean; // 是否全部选中 showNumber: number; // 筛选结果数量 inputValue: string; // input搜索框的值 onSearch: (searchString: string) => any; // 搜索框变化时应调用的函数,入参为搜索值 onAllClick: () => void; // 左侧全部按钮点击时应调用的函数 onSelectOrRemove: (item: Item) => any; //选择、删除单个选项时应调用的函数,入参应为当前操作item onSelect: (value:Array)=>void; // 受控批量选中key selectedItem: Map, // 所有已选项的集合 } ``` `renderSelectedPanel: (selectedPanelProps: SelectedPanelProps) => ReactNode` `SelectedPanelProps`包含以下参数 ```ts interface SelectedPanelProps { length: number; // 已选项的数量 onClear: () => void; // 点击清空时应调用的回调函数 onRemove: (item: Item) => void; // 删除单个选项时应调用的函数 onSortEnd: ({ oldIndex, newIndex}) => void; // 对结果重新排序时应调用的函数 selectedData: Array; // 所有已选项集合 } ``` ```jsx live=true dir="column" import React from 'react'; import { Transfer, Input, Spin, Button } from '@douyinfe/semi-ui'; import { IconSearch } from '@douyinfe/semi-icons'; class CustomRenderDemo extends React.Component { constructor(props) { super(props); this.state = { dataSource: Array.from({ length: 100 }, (v, i) => ({ label: `海底捞门店 ${i}`, value: i, disabled: false, key: i, })), }; this.renderSourcePanel = this.renderSourcePanel.bind(this); this.renderSelectedPanel = this.renderSelectedPanel.bind(this); this.renderItem = this.renderItem.bind(this); } renderItem(type, item, onItemAction, selectedItems) { let buttonText = '删除'; if (type === 'source') { let checked = selectedItems.has(item.key); buttonText = checked ? '删除' : '添加'; } return (

{item.label}

); } renderSourcePanel(props) { const { loading, noMatch, filterData, selectedItems, allChecked, onAllClick, inputValue, onSearch, onSelectOrRemove, } = props; let content; switch (true) { case loading: content = ; break; case noMatch: content =
{inputValue ? '无搜索结果' : '暂无内容'}
; break; case !noMatch: content = filterData.map(item => this.renderItem('source', item, onSelectOrRemove, selectedItems)); break; default: content = null; break; } return (
门店列表
} onChange={onSearch} showClear />
待选门店: {filterData.length}
{content}
); } renderSelectedPanel(props) { const { selectedData, onClear, clearText, onRemove } = props; let mainContent = selectedData.map(item => this.renderItem('selected', item, onRemove)); if (!selectedData.length) { mainContent =
暂无数据,请从左侧筛选
; } return (
已选同步门店: {selectedData.length}
{mainContent}
); } render() { const { dataSource } = this.state; return ( console.log(values)} className="component-transfer-demo-custom-panel" renderSourcePanel={this.renderSourcePanel} renderSelectedPanel={this.renderSelectedPanel} dataSource={dataSource} /> ); } } ``` ```scss .component-transfer-demo-custom-panel { .sp-font { color: rgba(var(--semi-grey-9), 1); font-size: 12px; font-weight: 500; line-height: 20px; } .empty { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; } .panel-item { flex-shrink: 0; height: 56px; border-radius: 4px; padding: 8px 12px; flex-wrap: wrap; background-color: rgba(22, 24, 35, .03); &-main { flex-grow: 1; } p { margin: 0 12px; flex-basis: 100%; } .panel-item-remove { cursor: pointer; color: var(--semi-color-primary); } } .panel-header { padding: 10px 12px; border: 1px solid rgba(22, 24, 35, .16); border-radius: 4px 4px 0 0; height: 38px; box-sizing: border-box; background-color: var(--semi-color-tertiary-light-default); display: flex; align-items: center; justify-content: space-between; .clear { cursor: pointer; color: var(--semi-color-primary); } } .source-panel { display: flex; flex-direction: column; width: 482px; height: 353px; .panel-main { border: 1px solid var(--semi-color-border); border-top: none; .panel-list { display: flex; flex-wrap: wrap; row-gap: 8px; column-gap: 8px; overflow-y: auto; height: 214px; margin-left: 12px; margin-right: 12px; padding-bottom: 8px; } } .panel-controls { margin: 10px 12px; font-size: 12px; line-height: 20px; .semi-button { margin-left: 8px; font-size: 12px; } } .panel-item { width: 176px; } margin-right: 16px; } .selected-panel { width: 200px; height: 353px; .panel-main { display: flex; flex-direction: column; overflow-y: auto; padding: 12px; border: 1px solid var(--semi-color-border); border-top: none; height: 323px; box-sizing: border-box; row-gap: 8px; } } } ``` ### 完全自定义渲染 、 拖拽排序 在完全自定义渲染的场景下,由于拖拽区的渲染也已由你完全接管,因此你不声明 draggable 亦可。 但你需要自行实现拖拽逻辑,我们推荐直接使用`react-sortable-hoc` 要支持拖拽排序,你需要在拖拽排序结束后,将 oldIndex、newIndex 作为入参,调用 onSortEnd ```jsx live=true dir="column" import React from 'react'; import { SortableContainer, SortableElement, sortableHandle } from 'react-sortable-hoc'; import { Transfer, Button, Spin, Input } from '@douyinfe/semi-ui'; import { IconHandle, IconSearch } from '@douyinfe/semi-icons'; class CustomRenderDragDemo extends React.Component { constructor(props) { super(props); this.state = { dataSource: Array.from({ length: 100 }, (v, i) => ({ label: `海底捞门店 ${i}`, value: i, disabled: false, key: i, })), }; this.renderSourcePanel = this.renderSourcePanel.bind(this); this.renderSelectedPanel = this.renderSelectedPanel.bind(this); this.renderItem = this.renderItem.bind(this); } renderItem(type, item, onItemAction, selectedItems) { let buttonText = '删除'; let newItem = item; if (type === 'source') { let checked = selectedItems.has(item.key); buttonText = checked ? '删除' : '添加'; } else { // delete newItem._optionKey; newItem = { ...item, key: item._optionKey }; delete newItem._optionKey; } const DragHandle = sortableHandle(() => ); return (
{type === 'source' ? null : }

{item.label}

); } renderSourcePanel(props) { const { loading, noMatch, filterData, selectedItems, allChecked, onAllClick, inputValue, onSearch, onSelectOrRemove, } = props; let content; switch (true) { case loading: content = ; break; case noMatch: content =
{inputValue ? '无搜索结果' : '暂无内容'}
; break; case !noMatch: content = filterData.map(item => this.renderItem('source', item, onSelectOrRemove, selectedItems)); break; default: content = null; break; } return (
门店列表
} onChange={onSearch} showClear />
待选门店: {filterData.length}
{content}
); } renderSelectedPanel(props) { const { selectedData, onClear, clearText, onRemove, onSortEnd } = props; let mainContent = null; if (!selectedData.length) { mainContent =
暂无数据,请从左侧筛选
; } const SortableItem = SortableElement(item => this.renderItem('selected', item, onRemove)); const SortableList = SortableContainer( ({ items }) => { return (
{items.map((item, index) => ( // sortableElement will take over the property 'key', so use another '_optionKey' to pass // otherwise you can't get `key` property in this.renderItem ))}
); }, { distance: 10 } ); mainContent = ; return (
已选同步门店: {selectedData.length}
{mainContent}
); } render() { const { dataSource } = this.state; return ( console.log(values)} className="component-transfer-demo-custom-panel" renderSourcePanel={this.renderSourcePanel} renderSelectedPanel={this.renderSelectedPanel} dataSource={dataSource} /> ); } } ``` ### 树穿梭框 传入 type 为`treeList`,使用[`Tree`](/zh-CN/navigation/tree)组件作为自定义渲染列表。**v1.20.0 提供** 可通过treeProps([TreeProps](/zh-CN/navigation/tree#Tree))来覆盖默认树的属性,左侧树默认属性为 ``` { multiple:true, disableStrictly:true, leafOnly:true, filterTreeNode:true, searchRender:flase, } ``` ```jsx live=true dir="column" import React, { useState } from 'react'; import { Transfer } from '@douyinfe/semi-ui'; () => { const treeData = [ { label: 'Asia', value: 'Asia', key: '0', children: [ { label: 'China', value: 'China', key: '0-0', children: [ { label: 'Beijing', value: 'Beijing', key: '0-0-0', }, { label: 'Shanghai', value: 'Shanghai', key: '0-0-1', }, { label: 'Chengdu', value: 'Chengdu', key: '0-0-2', }, ], }, { label: 'Japan', value: 'Japan', key: '0-1', children: [ { label: 'Osaka', value: 'Osaka', key: '0-1-0', }, ], }, ], }, { label: 'North America', value: 'North America', key: '1', children: [ { label: 'United States', value: 'United States', key: '1-0', }, { label: 'Canada', value: 'Canada', key: '1-1', }, { label: 'Mexico', value: 'Mexico', disabled: true, key: '1-2', }, { label: 'Cuba', value: 'Cuba', key: '1-3', }, ], }, ]; const [v, $v] = useState(['Shanghai']); return (
); }; ``` ## Accessibility ### ARIA - 搜索框添加 `role` `search` - 右侧选中列表添加 `role` `list`,选中项添加 `role` `listitem` ## API 参考 ### Trasnfer Props | 属性 | 说明 | 类型 | 默认值 | 版本 | | --- | --- | --- | --- | --- | | className | 样式类名 | string | | | | dataSource | 数据源 | Array\|Array\|Array | [] | | | defaultValue | 默认已选中值 | Array | | | | disabled | 是否禁用 | boolean | false | | | draggable | 是否开启拖拽排序 | boolean | false | | | emptyContent | 自定义空状态的提示文本,search 为无搜索结果时展示的文本,left 为左侧无源数据时的文本,right 为无勾选数据时的提示文本 | {left: ReactNode; right: ReactNode; search: ReactNode;} | | | | filter | 自定义筛选逻辑, 当为 false 时,不展示搜索框 | boolean \| (input:string, item: Item) => boolean | true | | | inputProps | 可用于自定义搜索框 Input,可配置属性参考 Input 组件 | [InputProps](/zh-CN/input/input#API%20%E5%8F%82%E8%80%83) | | | | loading | 是否正在加载左侧选项 | boolean | - | | | onChange | 选中值发生变化时触发的回调, 拖拽排序变化后也会触发该回调 | (values: Array, items: Array) => void | | | | onDeselect | 取消勾选时的回调 | (item: Item) => void | | | | onSearch | 搜索框输入内容变化时调用 | (inputValue: string) => void | | | | onSelect | 勾选时的回调 | (item: Item) => void | | | | renderSelectedItem | 自定义右侧单个已选项的渲染 | (item: { onRemove, sortableHandle } & Item) => ReactNode | | | | renderSelectedPanel | 自定义右侧已选面板的渲染 | (selectedPanelProps) => ReactNode | | 1.11.0 | | renderSourceItem | 自定义左侧单个候选项的渲染 | (item: { onChange, checked } & Item) => ReactNode | | | | renderSourcePanel | 自定义左侧候选面板的渲染 | (sourcePanelProps) => ReactNode | | 1.11.0 | | showPath | 当 type 为`treeList`时,控制右侧选中项是否显示选择路径 | boolean | false | 1.20.0 | | style | 内联样式 | CSSProperties | | | | treeProps | 当 type 为`treeList`时,可作为 TreeProps 传入左侧的 Tree 组件 | [TreeProps](/zh-CN/navigation/tree#Tree) | | 1.20.0 | | type | Transfer 类型,可选`list`,`groupList`,`treeList` | string | 'list' | 1.20.0 | | value | 已选中值,传入该项时,将作为受控组件使用 | Array | | | ### Item Interface | 属性 | 说明 | 类型 | 默认值 | | --------- | ------------------------------------ | ---------------- | ------ | | className | 样式类名 | string | | | disabled | 是否禁用 | boolean | false | | key | 必填,每个选项的唯一标识,不允许重复 | string \| number | | | label | 选项展示内容 | ReactNode | | | style | 内联样式 | CSSProperties | | | value | 选项代表的值 | string \| number | | ### GroupItem Interface GroupItem继承Item的所有属性 | 属性 | 说明 | 类型 | 默认值 | | -------- | ------------ | --------------------------------- | ------ | | children | 该分组的元素 | Array | | | title | 分组名称 | string | | ### TreeItem Interface TreeItem 继承 Item 的所有属性 | 属性 | 说明 | 类型 | 默认值 | | -------- | ------ | ---------------- | ------ | | children | 子元素 | Array | | ## 设计变量