教程:如何使用 React Native 构建 Slack 克隆(第一部分)
React Native在移动开发领域占有重要地位。随着每个新版本的发布,它的开发速度和性能也越来越出色。构建一个聊天应用程序曾经是一项艰巨的工作,但借助 React-native 和Stream Chat的强大功能,只需几分钟即可创建一个消息应用程序。
在本教程中,我们将构建一个 Slack 的克隆版本,Slack 是一个面向工作场所的消息平台。Slack 应用程序具有丰富的功能。在本教程的这一部分,我们将介绍 Slack 的以下 UI/UX 功能:
- 频道列表导航
- 输入框
- 消息行
- 反应列表
- Giphy 卡片
- 丰富的 URL 预览
结果将如下所示:
注意:本教程的目的并非帮助您构建一个可用于生产的 Slack 应用程序克隆版本(因为它已经存在)。相反,本教程将作为一个入门指南,帮助您了解如何使用 Stream 的聊天和消息传递 API 及 SDK 提供的 UI 组件构建真实的聊天功能。
如果您在本教程中感到迷茫,以下资源将会有所帮助:
注意:如果您想向应用程序发送消息,以检查实时功能是否正常工作,请使用此CodePen。
资源👇
如果您在途中遇到困难,以下几个链接可以为您提供帮助:
快速测试🥽
如果您希望快速查看应用程序的最终运行状态,请克隆以下 slack clone 的 expo 示例并在模拟器或手机上运行它:
步骤 1:设置🛠️
开发环境设置
在开始之前,请确保你已经为 react-native 设置好了开发环境。请阅读react-native 官方文档的“安装依赖项”部分。
项目设置
设置好开发环境后,创建一个新的 react-native 应用程序:
Slack 使用 Lato 字体,该字体可在https://fonts.google.com/上免费获取。为了达到视觉效果,我们需要将该字体导入到我们的应用中。为此,请react-native.config.js
在项目目录中创建一个名为的文件,并粘贴以下内容:
slack-clone
您可以从项目存储库下载 Lato 字体文件,并从这里下载图标。
或者,您可以从Google Fonts 网站下载字体。您会Download family
在顶部看到一个标题按钮。
接下来在项目根目录下准备如下目录结构:
请在此步骤运行以下命令:
完成这些步骤后,您的 slack-clone 应用所需的设置就完成了。现在,您应该能够使用以下命令在模拟器上启动该应用。启动后,您将看到 React Native 的欢迎屏幕。
步骤 2:组件
基本导航抽屉
让我们首先在应用中创建一个基本的抽屉导航。将 的内容替换App.js
为以下代码:
完成此操作后,如果您检查模拟器,您应该会看到基本的类似 Slack 的抽屉导航。
频道列表导航🧭
现在,让我们创建一个频道列表导航,并将其添加到刚刚创建的抽屉中。对于 Slack 导航抽屉,我们将重点关注以下一些基本 UI 元素:
- 频道分组依据
- 未读频道
- 频道(读取频道)
- 直接消息 - 这是react-native 中SectionList的完美用例
- 未读频道标签以粗体显示
- 直接消息用户的名字旁边有一个在线指示器 - 如果他们在线则为绿色,否则为空心圆圈。
让我们创建一个名为 的文件src/components/ChannelList.js
。您可以将以下代码片段的内容复制到新创建的文件中:
此外,用以下内容替换ChannelListDrawer
组件App.js
:
如果你熟悉 React-native,这段代码应该相当直观。我们添加了一个SectionList
包含三个部分的组件:未读消息、频道消息、私信消息。目前你应该在应用中看到以下内容:
现在让我们填充SectionList
一些频道。正如我在本教程前面提到的,我们将使用 Stream 的聊天基础架构。
让我们首先创建一个 Stream Chat 客户端App.js
并将其作为 prop 传递给ChannelList
组件。
我们还添加了一个名为 的 prop 函数changeChannel
,它负责打开频道屏幕并将提供的频道 ID 传递给它。我们将使用此函数作为onPress
的处理程序ChannelListItem
。
现在让我们在ChannelList.js
文件中创建一个钩子,用于查询频道。稍后,当有新消息到达或在群组之间移动消息时,我们会实时更新它们。
如果您不熟悉 React hooks,这里有一些很棒的入门资源:
此钩子使用 Stream 客户端查询频道。它将频道分为三类,并作为状态变量返回unreadChannels
:readChannels
oneOnOneConversations
该renderChannelListItem
函数当前返回<Text>{channel.id}</Text>
,显示频道的 ID。让我们为该项目创建一个类似 Slack 的 UI。
在名为 的单独文件中创建一个新组件src/components/ChannelListItem.js
。
该组件将根据群组频道、一对一对话或未读频道来确保不同的样式。它还会检查是否包含用户提及。
现在让我们在组件ChannelListItem
中使用我们的组件。ChannelList
SectionList
import React, {useState, useEffect} from 'react'; | |
import { | |
View, | |
Text, | |
SafeAreaView, | |
TextInput, | |
StyleSheet, | |
SectionList, | |
} from 'react-native'; | |
import {ChannelListItem} from './ChannelListItem'; | |
export const ChannelList = ({client, changeChannel}) => { | |
const { | |
activeChannelId, | |
setActiveChannelId, | |
unreadChannels, | |
readChannels, | |
oneOnOneConversations, | |
} = useWatchedChannels(client, changeChannel); | |
const renderChannelRow = (channel, isUnread) => { | |
const isOneOnOneConversation = | |
Object.keys(channel.state.members).length === 2; | |
return ( | |
<ChannelListItem | |
activeChannelId={activeChannelId} | |
setActiveChannelId={setActiveChannelId} | |
changeChannel={changeChannel} | |
isOneOnOneConversation={isOneOnOneConversation} | |
isUnread={isUnread} | |
channel={channel} | |
client={client} | |
key={channel.id} | |
currentUserId={client.user.id} | |
/> | |
); | |
}; | |
return ( | |
<SafeAreaView> | |
<View style={styles.container}> | |
<View style={styles.headerContainer}> | |
<TextInput | |
style={styles.inputSearchBox} | |
placeholderTextColor="grey" | |
placeholder="Jump to" | |
/> | |
</View> | |
<SectionList | |
style={styles.sectionList} | |
sections={[ | |
{ | |
title: 'Unread', | |
id: 'unread', | |
data: unreadChannels || [], | |
}, | |
{ | |
title: 'Channels', | |
data: readChannels || [], | |
}, | |
{ | |
title: 'Direct Messages', | |
data: oneOnOneConversations || [], | |
}, | |
]} | |
keyExtractor={(item, index) => item.id + index} | |
renderItem={({item, section}) => { | |
return renderChannelRow(item, section.id === 'unread'); | |
}} | |
renderSectionHeader={({section: {title}}) => ( | |
<View style={styles.groupTitleContainer}> | |
<Text style={styles.groupTitle}>{title}</Text> | |
</View> | |
)} | |
/> | |
</View> | |
</SafeAreaView> | |
); | |
}; |
正如你在这里注意到的,我提供了isUnread: true
未读部分的数据。这样,我就可以告诉renderChannelRow
函数当前要渲染的通道是否为未读通道。
renderChannelRow
这不是必需的,因为您可以快速获取正在使用的频道的未读计数,channel.unreadCount()
以判断该消息是否已读。但这只是为了避免额外调用channel.countUnread()
,这本质上是循环遍历消息。
如果您重新加载应用程序,您应该会看到频道列表中填充了几个频道,如下面的屏幕截图所示:
到目前为止,ChannelList
一切正常,但你会注意到它不是实时的。如果其他用户在某个频道发送了消息,它不会反映在你的 上。为此,ChannelList
我们需要在钩子中实现事件处理程序。useWatchedChannels
您可以在此处找到有关 Stream 事件的详细文档。
出于教程目的,我们将处理两个事件,但您可以根据需要尝试任意数量的事件:
message.new
- 此事件告诉我们某个频道上有一条新消息(频道数据包含在事件对象中)。在本例中,我们希望将频道从readChannels
或oneOnOneConversations
移动到unreadChannels
。message.read
- 此事件告诉我们某个通道(事件对象中可用的数据)被标记为已读。在本例中,我们希望将通道从 移动unreadChannels
到readChannels
或oneOnOneConversations
。
将钩子代码替换useWatchedChannels
为以下更新的代码:
const useWatchedChannels = (client, changeChannel) => { | |
const [activeChannelId, setActiveChannelId] = useState(null); | |
const [unreadChannels, setUnreadChannels] = useState([]); | |
const [readChannels, setReadChannels] = useState([]); | |
const [oneOnOneConversations, setOneOnOneConversations] = useState([]); | |
const [hasMoreChannels, setHasMoreChannels] = useState(true); | |
const filters = { | |
type: 'messaging', | |
example: 'slack-demo', | |
members: { | |
$in: [client.user.id], | |
}, | |
}; | |
const sort = {has_unread: -1, cid: -1}; | |
const options = {limit: 30, state: true}; | |
useEffect(() => { | |
if (!hasMoreChannels) { | |
return; | |
} | |
let offset = 0; | |
const _unreadChannels = []; | |
const _readChannels = []; | |
const _oneOnOneConversations = []; | |
/** | |
* fetchChannels simply gets the channels from queryChannels endpoint | |
* and sorts them by following 3 categories: | |
* | |
* - Unread channels | |
* - Channels (read channels) | |
* - Direct conversations/messages | |
*/ | |
async function fetchChannels() { | |
const channels = await client.queryChannels(filters, sort, { | |
...options, | |
offset, | |
}); | |
offset = offset + channels.length; | |
channels.forEach((c) => { | |
if (c.countUnread() > 0) { | |
_unreadChannels.push(c); | |
} else if (Object.keys(c.state.members).length === 2) { | |
_oneOnOneConversations.push(c); | |
} else { | |
_readChannels.push(c); | |
} | |
}); | |
setUnreadChannels([..._unreadChannels]); | |
setReadChannels([..._readChannels]); | |
setOneOnOneConversations([..._oneOnOneConversations]); | |
if (channels.length === options.limit) { | |
fetchChannels(); | |
} else { | |
setHasMoreChannels(false); | |
setActiveChannelId(_readChannels[0].id); | |
changeChannel(_readChannels[0].id); | |
} | |
} | |
fetchChannels(); | |
}, [client]); | |
useEffect(() => { | |
function handleEvents(e) { | |
if (e.type === 'message.new') { | |
const cid = e.cid; | |
// Check if the channel (which received new message) exists in group channels. | |
const channelReadIndex = readChannels.findIndex( | |
(channel) => channel.cid === cid, | |
); | |
if (channelReadIndex >= 0) { | |
// If yes, then remove it from reacChannels list and add it to unreadChannels list | |
const channel = readChannels[channelReadIndex]; | |
readChannels.splice(channelReadIndex, 1); | |
setReadChannels([...readChannels]); | |
setUnreadChannels([channel, ...unreadChannels]); | |
} | |
// Check if the channel (which received new message) exists in oneOnOneConversations list. | |
const oneOnOneConversationIndex = oneOnOneConversations.findIndex( | |
(channel) => channel.cid === cid, | |
); | |
if (oneOnOneConversationIndex >= 0) { | |
// If yes, then remove it from oneOnOneConversations list and add it to unreadChannels list | |
const channel = oneOnOneConversations[oneOnOneConversationIndex]; | |
oneOnOneConversations.splice(oneOnOneConversationIndex, 1); | |
setOneOnOneConversations([...oneOnOneConversations]); | |
setUnreadChannels([channel, ...unreadChannels]); | |
} | |
// Check if the channel (which received new message) already exists in unreadChannels. | |
const channelUnreadIndex = unreadChannels.findIndex( | |
(channel) => channel.cid === cid, | |
); | |
if (channelUnreadIndex >= 0) { | |
const channel = unreadChannels[channelUnreadIndex]; | |
unreadChannels.splice(channelUnreadIndex, 1); | |
setReadChannels([...readChannels]); | |
setUnreadChannels([channel, ...unreadChannels]); | |
} | |
} | |
if (e.type === 'message.read') { | |
if (e.user.id !== client.user.id) { | |
return; | |
} | |
const cid = e.cid; | |
// get channel index | |
const channelIndex = unreadChannels.findIndex( | |
(channel) => channel.cid === cid, | |
); | |
if (channelIndex < 0) { | |
return; | |
} | |
// get channel from channels | |
const channel = unreadChannels[channelIndex]; | |
unreadChannels.splice(channelIndex, 1); | |
setUnreadChannels([...unreadChannels]); | |
if (Object.keys(channel.state.members).length === 2) { | |
setOneOnOneConversations([channel, ...oneOnOneConversations]); | |
} else { | |
setReadChannels([channel, ...readChannels]); | |
} | |
} | |
} | |
client.on(handleEvents); | |
return () => { | |
client.off(handleEvents); | |
}; | |
}, [client, readChannels, unreadChannels, oneOnOneConversations]); | |
return { | |
activeChannelId, | |
setActiveChannelId, | |
unreadChannels, | |
setUnreadChannels, | |
readChannels, | |
setReadChannels, | |
oneOnOneConversations, | |
setOneOnOneConversations, | |
}; | |
}; |
我们在这里添加了另一个useEffect
钩子,它将事件监听器添加到我们的流客户端,并在组件卸载时负责移除该监听器。这handleEvent
是一个事件处理程序,它会根据接收到的事件采取相应的操作。
作为练习,您可以尝试添加其他事件的处理程序,例如
user.presence.changed
,channel.updated
或channel.deleted
现在尝试从这个CodePen向某个频道发送一条消息(使用用户Tommaso
),你应该会看到带有新消息的频道移至未读部分。
现在我们需要处理的最后一件事是的onclick
处理程序ChannelListItem
。当选择一个项目时,我们需要更新中的通道ChannelScreen
。
我们的组件到此就完成了ChannelList
。如果你向此列表中的某个频道发送一条消息,你将看到事件处理程序执行相应的操作,并更新列表 UI。
频道屏幕📱
让我们首先构建如下所示的频道标题:
为标题创建一个新文件 - src/components/ChannelHeader.js
:
import React from 'react'; | |
import {TouchableOpacity, View, Text, Image, StyleSheet} from 'react-native'; | |
import iconSearch from '../images/icon-search.png'; | |
import iconThreeDots from '../images/icon-3-dots.png'; | |
export const ChannelHeader = ({navigation, channel, client}) => { | |
let channelTitle = '#channel_name'; | |
// For normal group channel/conversation, its channel name as display title. | |
if (channel && channel.data && channel.data.name) { | |
channelTitle = '# ' + channel.data.name.toLowerCase().replace(' ', '_'); | |
} | |
const memberIds = | |
channel && channel.state ? Object.keys(channel.state.members) : []; | |
// Check if its oneOneOneConversation. | |
if (channel && memberIds.length === 2) { | |
// If yes, then use name of other user in conversation as channel display title. | |
const otherUserId = | |
memberIds[0] === client.user.id ? memberIds[1] : memberIds[0]; | |
channelTitle = channel.state.members[otherUserId].user.name; | |
} | |
return ( | |
<View style={styles.container}> | |
<View style={styles.leftContent}> | |
<TouchableOpacity | |
onPress={() => { | |
navigation.openDrawer(); | |
}}> | |
<Text style={styles.hamburgerIcon}>☰</Text> | |
</TouchableOpacity> | |
<Text style={styles.channelTitle}>{channelTitle}</Text> | |
</View> | |
{/* Message search and menu popup are not functional here. We will cover them in some future tutorial. */} | |
<View style={styles.rightContent}> | |
<TouchableOpacity style={styles.searchIconContainer}> | |
<Image source={iconSearch} style={styles.searchIcon} /> | |
</TouchableOpacity> | |
<TouchableOpacity style={styles.menuIconContainer}> | |
<Image source={iconThreeDots} style={styles.menuIcon} /> | |
</TouchableOpacity> | |
</View> | |
</View> | |
); | |
}; | |
export const styles = StyleSheet.create({ | |
container: { | |
padding: 15, | |
flexDirection: 'row', | |
backgroundColor: 'white', | |
justifyContent: 'space-between', | |
borderBottomWidth: 0.5, | |
borderBottomColor: 'grey', | |
}, | |
leftContent: { | |
flexDirection: 'row', | |
}, | |
hamburgerIcon: { | |
fontSize: 27, | |
}, | |
channelTitle: { | |
color: 'black', | |
marginLeft: 10, | |
fontWeight: '900', | |
fontSize: 17, | |
fontFamily: 'Lato-Regular', | |
}, | |
rightContent: { | |
flexDirection: 'row', | |
marginRight: 10, | |
}, | |
searchIconContainer: {marginRight: 15, alignSelf: 'center'}, | |
searchIcon: { | |
height: 18, | |
width: 18, | |
}, | |
menuIcon: { | |
height: 18, | |
width: 18, | |
}, | |
menuIconContainer: {alignSelf: 'center'}, | |
}); |
为此,我们在屏幕左侧添加了一个汉堡图标,单击该图标将打开导航抽屉。
我们尚未将其放入ChannelHeader
我们的组件中ChannelScreen
。
使用以下内容更新ChannelScreen
组件:App.js
import {ChannelHeader} from './src/components/ChannelHeader'; | |
import React, {useEffect, useState} from 'react'; | |
function ChannelScreen({navigation, route}) { | |
const [channel, setChannel] = useState(null); | |
useEffect(() => { | |
if (!channel) { | |
navigation.openDrawer(); | |
} | |
const channelId = route.params ? route.params.channelId : null; | |
const _channel = chatClient.channel('messaging', channelId); | |
setChannel(_channel); | |
}, [route.params]); | |
return ( | |
<SafeAreaView style={styles.channelScreenSaveAreaView}> | |
<View style={styles.channelScreenContainer}> | |
<ChannelHeader | |
navigation={navigation} | |
channel={channel} | |
client={chatClient} | |
/> | |
</View> | |
</SafeAreaView> | |
); | |
} |
如果您重新加载应用程序,您应该会看到一个空白的频道屏幕,顶部有标题:
现在让我们继续将MessageList
和MessageInput
组件添加到我们的ChannelScreen
。
这两个组件由 Stream 作为react-native-sdk的一部分提供。
ChannelScreen
请使用以下内容更新组件:
import { | |
Chat, | |
MessageList, | |
MessageInput, | |
Channel, | |
} from 'stream-chat-react-native'; | |
function ChannelScreen({navigation, route}) { | |
const [channel, setChannel] = useState(null); | |
useEffect(() => { | |
if (!channel) { | |
navigation.openDrawer(); | |
} | |
const channelId = route.params ? route.params.channelId : null; | |
const _channel = chatClient.channel('messaging', channelId); | |
setChannel(_channel); | |
}, [route.params]); | |
return ( | |
<SafeAreaView style={styles.channelScreenSaveAreaView}> | |
<View style={styles.channelScreenContainer}> | |
<ChannelHeader | |
navigation={navigation} | |
channel={channel} | |
client={chatClient} | |
/> | |
<View style={styles.chatContainer}> | |
<Chat client={chatClient}> | |
<Channel channel={channel}> | |
<MessageList /> | |
<MessageInput /> | |
</Channel> | |
</Chat> | |
</View> | |
</View> | |
</SafeAreaView> | |
); | |
} |
完成此更改后,您将在我们的频道屏幕底部看到消息和输入框。
但它看起来不太像 Slack 消息。所以现在我们需要做一些修改,让它看起来像 Slack。以下是 Slack UI 中与应用当前 UI 区别开来的部分。
- 用户名显示在消息顶部
- 头像(消息旁边的圆形用户个人资料图片)应该是正方形
- 回应应该在消息底部
- 每个反应旁边应显示反应计数
- URL 预览应该有一个粗的左灰色边框,并且其内容对齐偏移量
- 所有消息都应显示在屏幕左侧
- GIF 在 Slack 频道中的显示方式有所不同
- 消息之间的日期分隔符应显示在灰线上方
- 发送和附加按钮应位于输入框下方。
我们将逐一解决这些问题。Stream 的 React-native SDK 使用MessageSimple作为默认消息组件。但您也可以使用自定义 UI 组件作为消息组件——参考此处。
首先,让我们添加一些基本的自定义主题样式。让我们创建一个自定义消息组件(名为MessageSlack
),该组件内部使用了 MessageSimple 并进行了修改。该MessageSimple
组件提供了丰富的自定义功能。我们将为该组件支持的以下 props 创建自定义组件MessageSimple
。
注意:请查看食谱以获取更多示例)
- 消息头像
- MessageFooter(包含反应)
- MessageHeader(包含发件人的用户名)
- 消息文本
- UrlPreview(用于显示丰富的 URL 预览)
- Giphy(用于显示 Giphy 卡片)
让我们创建每个组件:
src/components/MessageSlack.js
import React from 'react'; | |
import {MessageSimple} from 'stream-chat-react-native'; | |
import {MessageFooter} from './MessageFooter'; | |
import {MessageText} from './MessageText'; | |
import {MessageAvatar} from './MessageAvatar'; | |
import {MessageHeader} from './MessageHeader'; | |
import {UrlPreview} from './UrlPreview'; | |
import {Giphy} from './Giphy'; | |
export const MessageSlack = props => { | |
if (props.message.deleted_at) { | |
return null; | |
} | |
return ( | |
<MessageSimple | |
{...props} | |
forceAlign="left" | |
ReactionList={null} | |
MessageAvatar={MessageAvatar} | |
MessageHeader={MessageHeader} | |
MessageFooter={MessageFooter} | |
MessageText={MessageText} | |
UrlPreview={UrlPreview} | |
Giphy={Giphy} | |
/> | |
); | |
}; |
src/components/MessageFooter.js
src/components/MessageHeader.js
import React from 'react'; | |
import {View, Text, StyleSheet} from 'react-native'; | |
import Moment from 'moment'; | |
export const MessageHeader = props => { | |
return ( | |
<View style={styles.column}> | |
{props.message.attachments.length > 0 && ( | |
<View style={styles.header}> | |
<MessageUserBar {...props} /> | |
</View> | |
)} | |
</View> | |
); | |
}; | |
export const MessageUserBar = ({groupStyles, message}) => { | |
if (groupStyles[0] === 'single' || groupStyles[0] === 'top') { | |
return ( | |
<View style={styles.userBar}> | |
<Text style={styles.messageUserName}>{message.user.name}</Text> | |
<Text style={styles.messageDate}> | |
{Moment(message.created_at).format('hh:ss A')} | |
</Text> | |
</View> | |
); | |
} | |
return null; | |
}; | |
const styles = StyleSheet.create({ | |
column: { | |
flexDirection: 'column', | |
}, | |
header: { | |
paddingLeft: 8, | |
}, | |
userBar: { | |
flexDirection: 'row', | |
alignItems: 'center', | |
marginBottom: 5, | |
}, | |
messageUserName: { | |
fontWeight: '900', | |
color: 'black', | |
fontSize: 15, | |
fontFamily: 'Lato-Bold', | |
}, | |
messageDate: { | |
color: 'grey', | |
marginLeft: 6, | |
fontSize: 10, | |
}, | |
}); |
src/components/MessageText.js
import React from 'react'; | |
import {MessageUserBar} from './MessageHeader'; | |
export const MessageText = props => { | |
return ( | |
<React.Fragment> | |
{props.message.attachments.length === 0 && <MessageUserBar {...props} />} | |
{props.renderText(props.message, props.theme.message.content.markdown)} | |
</React.Fragment> | |
); | |
}; |
src/components/MessageAvatar.js
import React from 'react'; | |
import {MessageAvatar as StreamMessageAvatar} from 'stream-chat-react-native'; | |
export const MessageAvatar = props => { | |
return ( | |
<StreamMessageAvatar | |
{...props} | |
showAvatar={ | |
props.groupStyles[0] === 'single' || props.groupStyles[0] === 'top' | |
? true | |
: false | |
} | |
/> | |
); | |
}; |
src/components/UrlPreview.js
import React from 'react'; | |
import {View, Text, TouchableOpacity, Image, StyleSheet} from 'react-native'; | |
export const UrlPreview = props => { | |
const getDomain = url => { | |
let domain = url && url.replace('https://', '').replace('http://', ''); | |
if (!domain) { | |
return url; | |
} | |
const indexOfSlash = domain.indexOf('/'); | |
if (indexOfSlash === -1) { | |
return domain; | |
} | |
return domain.slice(0, indexOfSlash); | |
}; | |
return ( | |
<TouchableOpacity style={styles.container}> | |
<View style={styles.detailsContainer}> | |
<Text style={styles.titleUrl}>{getDomain(props.title_link || props.og_scrape_url)}</Text> | |
<Text style={styles.title}>{props.title}</Text> | |
<Text style={styles.description}>{props.text}</Text> | |
</View> | |
<View style={styles.thumbnailContainer}> | |
<Image | |
source={{ | |
url: props.image_url || props.thumb_url, | |
}} | |
style={styles.thumbnail} | |
/> | |
</View> | |
</TouchableOpacity> | |
); | |
}; | |
const styles = StyleSheet.create({ | |
container: { | |
borderLeftWidth: 5, | |
borderLeftColor: '#E4E4E4', | |
paddingLeft: 10, | |
marginLeft: 10, | |
flexDirection: 'row', | |
}, | |
detailsContainer: { | |
flexDirection: 'column', | |
flex: 6, | |
}, | |
thumbnailContainer: { | |
flex: 1, | |
}, | |
thumbnail: { | |
height: 40, | |
width: 40, | |
}, | |
titleUrl: { | |
fontFamily: 'Lato-Regular', | |
fontWeight: 'bold', | |
padding: 2, | |
}, | |
title: { | |
fontFamily: 'Lato-Regular', | |
fontWeight: 'bold', | |
color: '#1E75BE', | |
padding: 2, | |
}, | |
description: { | |
fontFamily: 'Lato-Regular', | |
padding: 2, | |
}, | |
}); |
src/components/Giphy.js
import React from 'react'; | |
import {Text, TouchableOpacity, Image, StyleSheet} from 'react-native'; | |
export const Giphy = props => { | |
return ( | |
<TouchableOpacity style={styles.container}> | |
<Text style={styles.title}>{props.title}</Text> | |
<Text style={styles.description}>Posted using Giphy.com</Text> | |
<Image | |
source={{ | |
url: props.image_url || props.thumb_url, | |
}} | |
style={styles.thumbnail} | |
/> | |
</TouchableOpacity> | |
); | |
}; | |
const styles = StyleSheet.create({ | |
container: { | |
borderLeftWidth: 5, | |
borderLeftColor: '#E4E4E4', | |
paddingLeft: 10, | |
marginLeft: 10, | |
marginBottom: 10, | |
flexDirection: 'column', | |
}, | |
thumbnail: { | |
height: 150, | |
width: 250, | |
borderRadius: 10, | |
}, | |
title: { | |
fontFamily: 'Lato-Regular', | |
fontWeight: 'bold', | |
color: '#1E75BE', | |
padding: 2, | |
}, | |
description: { | |
fontFamily: 'Lato-Regular', | |
padding: 2, | |
fontSize: 13, | |
fontWeight: '300', | |
}, | |
}); |
我们还需要一个自定义DateSeparator
组件。Stream 默认使用的组件会将日期显示在中间的空格/线中。然而,在 Slack UI 中,日期显示在顶部,并以灰色的空格/线显示。
src/components/DateSeparator.js
import React from 'react'; | |
import {View, Text, StyleSheet} from 'react-native'; | |
import moment from 'moment'; | |
export const DateSeparator = ({message}) => { | |
return ( | |
<View style={styles.container}> | |
<Text style={styles.date}>{moment(message.date).calendar()}</Text> | |
<View style={styles.line} /> | |
</View> | |
); | |
}; | |
const styles = StyleSheet.create({ | |
container: { | |
flexDirection: 'column', | |
marginBottom: 10, | |
marginTop: 10, | |
}, | |
date: { | |
fontWeight: 'bold', | |
paddingBottom: 5, | |
fontSize: 12, | |
}, | |
line: { | |
flex: 1, | |
height: 0.5, | |
backgroundColor: '#E8E8E8', | |
}, | |
}); |
现在,在此之后,您需要做的就是将MessageSlack
和DateSeparator
传递MessageList
给App.js.
import {DateSeparator} from './src/components/DateSeparator'; | |
import {MessageSlack} from './src/components/MessageSlack'; | |
function ChannelScreen({navigation, route}) { | |
const [channel, setChannel] = useState(null); | |
useEffect(() => { | |
if (!channel) { | |
navigation.openDrawer(); | |
} | |
const channelId = route.params ? route.params.channelId : null; | |
const _channel = chatClient.channel('messaging', channelId); | |
setChannel(_channel); | |
}, [route.params]); | |
return ( | |
<SafeAreaView style={styles.channelScreenSaveAreaView}> | |
<View style={styles.channelScreenContainer}> | |
<ChannelHeader | |
navigation={navigation} | |
channel={channel} | |
client={chatClient} | |
/> | |
<View style={styles.chatContainer}> | |
<Chat client={chatClient}> | |
<Channel channel={channel}> | |
<MessageList | |
Message={MessageSlack} | |
DateSeparator={DateSeparator} | |
/> | |
<MessageInput /> | |
</Channel> | |
</Chat> | |
</View> | |
</View> | |
</SafeAreaView> | |
); | |
} |
如果您刷新应用程序,您将看到 UI 现在与 Slack UI 有更好的一致性。
我们还需要添加一些最后的修饰,比如方形头像。头像应该与消息顶部对齐,并且消息不应该有边框,所以我们还需要做一些小的对齐调整。
我们将通过主题化聊天组件来解决这些问题。请阅读 Stream 的 react-native聊天教程中的自定义样式部分。
创建一个名为的文件src/stream-chat-theme.js
:
export default { | |
'messageList.dateSeparator.date': 'color: black;', | |
'messageInput.container': | |
'border-top-color: #979A9A; border-top-width: 0.4; background-color: white; margin: 0; border-radius: 0;', | |
'messageList.dateSeparator.container': 'margin-top: 10; margin-bottom: 5;', | |
'message.avatarWrapper.spacer': 'height: 0;', | |
'messageInput.sendButtonIcon': 'height: 20px; width: 20px;', | |
'messageInput.attachButtonIcon': 'height: 20px; width: 20px;', | |
'messageInput.inputBox': 'font-size: 15;', | |
'message.content.container': | |
'flex: 1; align-items: stretch; max-width: 320px; padding-top: 0; border-radius: 0;', | |
'message.content.textContainer': | |
'align-self: stretch; padding-top: 0;margin-top: 0;border-color: white;width: 100%', | |
'message.container': 'margin-bottom: 0; margin-top: 0', | |
'message.avatarWrapper.container': 'align-self: flex-start', | |
'avatar.image': 'border-radius: 5;', | |
'message.card.container': | |
'border-top-left-radius: 8;border-top-right-radius: 8;border-bottom-left-radius: 8; border-bottom-right-radius: 8', | |
'message.gallery.single': | |
'border-top-left-radius: 8;border-top-right-radius: 8;border-bottom-left-radius: 8; border-bottom-right-radius: 8; margin-left: 5; width: 95%', | |
'message.gallery.galleryContainer': | |
'border-top-left-radius: 8;border-top-right-radius: 8;border-bottom-left-radius: 8; border-bottom-right-radius: 8; margin-left: 5; width: 95%', | |
'message.replies.messageRepliesText': 'color: #0064c2', | |
'message.content.markdown': { | |
text: { | |
fontSize: 16, | |
fontFamily: 'Lato-Regular', | |
} | |
}, | |
}; |
现在将此主题传递给App.js 中Chat
的组件ChannelScreen
,如下所示:
就这样!你应该会在屏幕上看到类似 Slack 的漂亮消息了。😺
输入框👨💻
现在让我们转到底部的输入框。该MessageInput
组件(来自 Stream)接受Input
一个自定义 UI 组件属性,用于显示在输入框中。让我们在 中创建这个自定义组件src/components/InputBox.js
。
import React from 'react'; | |
import {TouchableOpacity, View, Text, StyleSheet} from 'react-native'; | |
import { | |
AutoCompleteInput, | |
AttachButton, | |
SendButton, | |
} from 'stream-chat-react-native'; | |
export const InputBox = props => { | |
return ( | |
<View style={styles.container}> | |
<AutoCompleteInput {...props} /> | |
<View style={styles.actionsContainer}> | |
<View style={styles.row}> | |
<TouchableOpacity | |
onPress={() => { | |
props.appendText('@'); | |
}}> | |
<Text style={styles.textActionLabel}>@</Text> | |
</TouchableOpacity> | |
{/* Text editor is not functional yet. We will cover it in some future tutorials */} | |
<TouchableOpacity style={styles.textEditorContainer}> | |
<Text style={styles.textActionLabel}>Aa</Text> | |
</TouchableOpacity> | |
</View> | |
<View style={styles.row}> | |
<AttachButton {...props} /> | |
<SendButton {...props} /> | |
</View> | |
</View> | |
</View> | |
); | |
}; | |
const styles = StyleSheet.create({ | |
container: { | |
flexDirection: 'column', | |
flex: 1, | |
height: 60, | |
}, | |
actionsContainer: {flexDirection: 'row', justifyContent: 'space-between'}, | |
row: {flexDirection: 'row'}, | |
textActionLabel: { | |
color: '#787878', | |
fontSize: 18, | |
}, | |
textEditorContainer: { | |
marginLeft: 10, | |
}, | |
}); |
我们在 InputBox 中使用的以下组件由 Stream 的 react-native SDK 提供,它为我们处理了很多事情:
AutoCompleteInput
- 负责所有输入框功能,例如提及、发送消息、维护启用/禁用状态等。SendButton
AttachButton
我们所做的只是对 的内部组件进行改组MessageInput
。
这里需要注意的是,必须将整个 prop 对象传递给AutoCompleteInput
、SendButton
和AttachButton
。因此, 中的所有处理程序MessageInput
都可以被这些组件访问。
现在将此InputBox
组件传递给的MessageInput
组件。ChannelScreen
App.js
该组件的最终版本ChannelScreen
如下:
function ChannelScreen({navigation, route}) { | |
const [channel, setChannel] = useState(null); | |
useEffect(() => { | |
if (!channel) { | |
navigation.openDrawer(); | |
} | |
const channelId = route.params ? route.params.channelId : null; | |
const _channel = chatClient.channel('messaging', channelId); | |
setChannel(_channel); | |
}, [route.params]); | |
return ( | |
<SafeAreaView style={styles.channelScreenSaveAreaView}> | |
<View style={styles.channelScreenContainer}> | |
<ChannelHeader | |
navigation={navigation} | |
channel={channel} | |
client={chatClient} | |
/> | |
<View style={styles.chatContainer}> | |
<Chat client={chatClient} style={streamChatTheme}> | |
<Channel channel={channel}> | |
<MessageList | |
Message={MessageSlack} | |
DateSeparator={DateSeparator} | |
/> | |
<MessageInput | |
Input={InputBox} | |
additionalTextInputProps={{ | |
placeholderTextColor: '#979A9A', | |
placeholder: | |
channel && channel.data.name | |
? 'Message #' + | |
channel.data.name.toLowerCase().replace(' ', '_') | |
: 'Message', | |
}} | |
/> | |
</Channel> | |
</Chat> | |
</View> | |
</View> | |
</SafeAreaView> | |
); | |
} |
注意:MessageInput 组件额外增加了一个 prop -
additionalTextInputProps
,用于修改输入框的占位符。
恭喜!👏
这篇教程的第一部分就到这里,关于如何使用 Stream 的 React Native 聊天组件构建一个 Slack 克隆版本。希望本教程对您有所帮助,并期待您的反馈。
在本教程的下一部分(稍后发布)中,我们将介绍其他 UI 组件及其功能,例如:
- 线程
- 频道搜索
- 操作表
- 未读消息通知
- 还有更多!
编码愉快!
文章来源:https://dev.to/vishalnarkhede/tutorial-how-to-build-a-slack-clone-with-react-native-part-1-37kn