RN-组件

布局那些属实没什么好说的嗷,直接写组件吧

当然你也可以选择用 antd

Button

超常用组件 Button(其实是原生的和 antd 的都太难用了)

思考作为 button,我们需要什么属性

显然有颜色定制、点击事件定制、样式定制、文本定制等 4 个主要需求

同时我自己有一个彩色和黑白转换的需求

那么容易得到以下类型定义

1
2
3
4
5
6
7
8
interface ButtonProps {
backgroundColor?: string;
color?: string;
plain?: boolean;
onPress?: () => void;
children: React.ReactElement | string;
style?: any;
}

接下来写组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const Button = (props: ButtonProps) => {
const {plain = false, onPress = () => {}, children, style = {}} = props;

const {
backgroundColor = plain ? 'white' : '#5cd1f0',
color = plain ? 'black' : 'white',
} = props;

return (
<TouchableOpacity
style={{...styles.buttonWrapper, ...style, backgroundColor}}
onPress={onPress}>
<Text style={{...styles.button, color}}>{children}</Text>
</TouchableOpacity>
);
};

颜色我随便选的,看官请自便

先取除颜色外的值,并提供默认值,然后根据是否 plain 而取颜色,并设置不同的默认值

然后返回一个由 TouchableOpacity 构成的可点击组件,并注入样式和内容

最后写样式表

1
2
3
4
5
6
7
.buttonWrapper {
padding: 8px 16px;
border: 1px solid #ececec;
}
.button {
text-align: center;
}

使用方法:

1
2
3
4
5
6
7
8
9
import Button from './src/component/Button'

export default () => {
return (
<View>
<Button onPress={() => {console.log('hello world')}}>点我</Button>
</View>
)
}

此处选用 TouchableOpacity,是因为我反复验证后,觉得这个表现最好

详情可以参考文档:https://reactnative.cn/docs/touchableopacity

Tabs

对于 Tabs 组件,我们显然需要标签名、内容和 onChange 事件

所以容易得到如下类型定义

1
2
3
4
interface TabsProps {
children: React.ReactElement[];
onChange?: (selected: string) => void;
}

但是我们如何在用户变更选择的时候,切换渲染的内容呢?

所以我们容易得到一个 Tab 组件,该组件要求用户传入 name 属性,然后根据 name 来选择内容;同时要求 Tabs 组件只接受 Tab 作为直接子级

那么容易得到 Tab 组件代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import React from 'react';
import {View} from 'react-native';

interface TabProps {
name: string;
children?: React.ReactElement;
}

const Tab = (props: TabProps) => {
const {children} = props;
return <View>{children}</View>;
};

export default Tab;

进而可以得到 Tabs 组件代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
const Tabs = (props: TabsProps) => {
const {children, onChange = () => {}} = props;
const [titles, setTitles] = useState<string[]>([]);
const [selected, setSelected] = useState<string>(titles[0]);

useEffect(() => {
const names: string[] = [];
for (const el of children) {
if (el.type !== Tab || !('name' in el.props)) {
console.error('仅接受Tab标签作为Tabs的直接子级');
return;
}
if (names.includes(el.props.name)) {
console.error('name 重复');
return;
}
names.push(el.props.name);
}
setTitles(names);
setSelected(names[0]);
}, [children]);

useEffect(() => {
if (selected) {
onChange(selected);
}
}, [onChange, selected]);

return (
<View>
<View style={styles.tabs}>
{titles.map(title => (
<TouchableOpacity
key={title}
style={styles.tab}
onPress={() => setSelected(title)}>
<Text>{title}</Text>
<View style={{...(title === selected ? styles.selected : {})}} />
</TouchableOpacity>
))}
</View>
{children.find(el => el.props.name === selected)}
</View>
);
};

通过 useEffect 在初始化时检查直接子级是否符合要求

最后是样式表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.tabs {
flex-direction: row;
}
.tab {
flex-grow: 1;
flex-shrink: 1;
align-items: center;
padding: 12px;
}
.selected {
background-color: #5cd1f0;
position: absolute;
bottom: 0;
height: 4px;
width: 100%;
border-radius: 8px;
}

Loading

要制作 Loading 动画,我们了解 rn 原生的动画机制

文档:https://reactnative.cn/docs/animated

我们首先要从 react-native 中引入 AnimatedEasing

然后声明一个 value,用于在动画进程中作为变化值

类似于 css 原生动画中指示某个属性的变化,value 需要声明起点、终点和过度时间、过度函数

首先创建 value

1
const value = useRef(new Animated.Value(0)).current;

此处必须是通过 useRef 创建的一个引用,否则 value 就是常量,无法变化了

然后定义一个动画变化函数

1
2
3
4
5
6
7
8
9
const startAnime = () => {
value.setValue(0);
Animated.timing(value, {
toValue: 1,
duration: 2000,
easing: Easing.linear,
useNativeDriver: true,
}).start();
};

该函数声明了动画的起点:setValue(0),以及动画的依赖

Animated.timing 用于创建一个动画,第一个参数是依赖的值,第二个参数是 options,包括以下参数:

参数 含义
toValue 动画变化值的终点值
duration 变化的时长,单位 ms
easing 动画的变化函数
useNativeDriver 是否启用原生动画

如果使用原生动画,则 rn 会在动画开始前将动画信息发送到原生代码,从而使得动画在原生平台的
UI 线程上执行,js 就可以去执行其它任务了

如果不启用,则动画是 js 对每一帧的桥接

特别的,如果启用原生动画,则所有依赖相同动画值的动画都要启用原生动画

动画创建完成后,通过 start 方法启动动画,默认只执行一次。动画可以通过 stop 方法手动中断,并且其它中断也都是调用了 stop 方法

start 方法可以接受一个函数作为参数,该函数接受一个包含 finished 属性的对象作为参数,表示动画结束后要执行的动作

当动画正常结束时,finished 会被置为 true,否则为 false

我们可以通过该函数来实现动画的无限循环

1
2
3
4
5
6
const startAnime = () => {
value.setValue(0);
Animated.timing(value, {
...options
}).start(({finished}) => {startAnime()};
};

到现在,我们已经创建了一个动画的变化,现在要渲染到视图上

我选用 <Icon name="loading" size={100} /> 作为动画的内容,并且让其旋转

旋转功能通过 css 的 transform rotate 来实现

则有如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<Animated.View
style={{
...styles.loading,
transform: [
{
// 因为 typeof Animated.Value === 'object',所以必须要转换一次
rotate: value.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
}),
},
],
}}>
<Icon name="loading" size={100} />
</Animated.View>

首先,根据动画值来设定样式的代码,必须在 Animated.View 上才会生效

然后,transform 接受一个数组,每个数组单位为一个对象,该对象描述了 transform 要执行的动作

此处我通过 interpolate 方法,创建了一个映射,将 value 的值范围映射到一个带有单位的范围上,以满足 rotate 的参数要求

interpolate 方法会自动识别单位,所以尽管写,它自己会适配

那么,一个旋转等待的动画就完成了

Html

rn 不支持直接嵌入 hmtl 片段,那我们总要想个办法支持一下

react-native-render-html

推荐通过 react-native-render-html 来实现这个需求

1
yarn add react-native-render-html

然后引入该库的默认导出

1
import RenderHtml from 'react-native-render-html';

该组件的使用方式如下

1
<RenderHtml source={{html}} contentWidth={width} />

如果你不提供 contentWidth 属性,则会触发警告,所以一般是提供

此处使用的 width 一般推荐用如下方式取得

1
2
3
import {useWindowDimensions} from 'react-native';

const {width} = useWindowDimensions();

那么易得组件代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React from 'react';
import {View, useWindowDimensions} from 'react-native';
import RenderHtml from 'react-native-render-html';

import styles from './index.less';

interface HtmlProps {
html: string;
}

const Html = (props: HtmlProps) => {
const {html} = props;
const {width} = useWindowDimensions();

return (
<View style={styles.html}>
<RenderHtml source={{html}} contentWidth={width} />
</View>
);
};

export default Html;

为了满足 /forum/[id] 的预览需求,易得样式表如下

1
2
3
4
.html {
max-height: 100%;
overflow: hidden;
}

唯一的缺点就是在 RenderHtml 内部引入 css 很麻烦,不过它已经是最好用的了,总不能用 WebView 吧

WebView

说到 WebView,顺便介绍一下 WebView 的实现

通过 react-native-webview 来实现这个需求

1
yarn add react-native-webview

然后引入该库的默认导出

1
import WebView from 'react-native-webview';

该组件的使用方式如下

1
2
3
4
5
6
7
8
9
10
<View style={{...styles.html, height}}>
<WebView
source={{html}}
injectedJavaScript="window.ReactNativeWebView.postMessage(document.documentElement.scrollHeight)"
onMessage={event => {
setHeight(+event.nativeEvent.data);
onInit(+event.nativeEvent.data);
}}
/>
</View>

通过 injectedJavaScript 以在 WebView 的内容初始化完成时,向父组件提交事件,可以类比于 html 的 iframe

通过 onMessage 来接收参数,并处理

同时,WebView 默认是 0 高度的,所以必须在初始化完成后,获取内部高度,然后设置到父组件上

也可以大量插入片段,并通过 link 来引入外源 css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<View style={{...styles.html, height}}>
<WebView
source={{
html: `<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link type="text/css" rel="stylesheet" href="https://webfiles.ringoer.com/github-markdown.css">
</head>

<body>
<article class="markdown-body">
${html}
</article>
</body>
</html>`,
}}
injectedJavaScript="window.ReactNativeWebView.postMessage(document.documentElement.scrollHeight)"
onMessage={event => {
setHeight(+event.nativeEvent.data);
onInit(+event.nativeEvent.data);
}}
/>
</View>

缺点是,当一个页面有大量 WebView 的时候,会重复引入很多次 link 内容,同时造成进程负担极重,无法正常卸载组件,导致应用崩溃闪退

所以我不建议用 WebView(

Editor

caho 是有 markdown 编辑需求的,所以需要一个 markdown Editor

其实就是缝合怪(?

容易得到需求

  1. 无权限禁用
  2. 标题
  3. 提交动作
  4. 默认值
  5. 最大长度限制
  6. 附加插入内容

那么可得类型定义

1
2
3
4
5
6
7
8
interface EditorProps {
disabled?: boolean;
hasTitle?: boolean;
onSubmit?: (text: string, title: string) => void;
defaultValue?: string;
wordsLimit?: number;
insertValue?: string;
}

因为标题不一定存在,所以提交动作的第一个值是正文,第二个才是标题

容易得到组件代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
const Editor = (props: EditorProps) => {
const {
disabled = false,
hasTitle = false,
onSubmit = () => {},
defaultValue = '',
wordsLimit = 4095,
insertValue,
} = props;
const [title, setTitle] = useState('');
const [text, setText] = useState<string>(defaultValue);

useEffect(() => {
if (insertValue) {
setText(`${text}${insertValue}`);
}
}, [text, insertValue]);

return (
<View style={styles.editorWrapper}>
<View style={styles.editor}>
{hasTitle ? (
<View style={styles.title}>
<Text>标题</Text>
<InputItem
style={styles.input}
value={title}
onChange={value => setTitle(value)}
/>
</View>
) : undefined}
<View style={styles.description}>
<Text>正文</Text>
<Text style={styles.support}>支持Markdown文本</Text>
</View>
<Tabs>
<Tab name="编辑">
<TextareaItem
style={styles.textarea}
rows={8}
count={wordsLimit}
defaultValue={disabled ? '' : text}
onChange={value => setText(value || '')}
/>
</Tab>
<Tab name="预览">
<Html html={marked(text)} />
</Tab>
</Tabs>
<View style={styles.actions}>
<Button
plain
onPress={() => {
Swal.confirm(
'即将对每个换行进行扩增,每 1 个扩增为 2 个\n您确定要这样做吗?',
).then(() => {
const value = text.split('\n').join('\n\n');
setText(value);
});
}}>
换行
</Button>
<Button
plain
onPress={() => {
setText('');
}}>
重置
</Button>
<Button
onPress={() => {
if (disabled) {
console.error('您在当前环境下没有权限进行编辑');
return;
} else if (!(onSubmit instanceof Function)) {
console.error('传入的提交函数非法');
return;
} else if (text.length > wordsLimit) {
console.error('正文长度超出限制');
return;
}
onSubmit(text, title);
}}>
提交
</Button>
</View>
</View>
{disabled ? (
<View style={styles.forbidden}>
<Text>您在当前环境下没有权限进行编辑</Text>
</View>
) : undefined}
</View>
);
};

扩增功能是因为其实并不是所有用户都会写 markdown,而 markdown 在写错换行符的时候是很烦的,所以要方便普通用户

然后是 forbidden 功能,鉴于 rn 的所有组件都是 box-sizing: content-box,所以要修改层级以使得阻止视图完整覆盖编辑器

所以可得样式表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
.editor {
border-radius: 4px;
padding: 12px;
}
.title {
margin-bottom: 16px;
}
.input {
border: 1px solid #acacac;
}
.description {
flex-direction: row;
}
.support {
margin-left: 4px;
color: #acacac;
}
.actions {
flex-direction: row;
justify-content: flex-end;
}
.forbidden {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
justify-content: center;
align-items: center;
z-index: 1;
}

感谢阅读

--It's the end.Thanks for your read.--