feat(animations): wip: smooth bubble-up transition

This commit is contained in:
Takuya Matsuyama 2022-06-27 14:50:14 +09:00
parent bddf2257fe
commit 2da621a0bd
6 changed files with 86 additions and 39 deletions

View file

@ -10,20 +10,30 @@ function App() {
const [messages, addMessage] = useMessages([]) const [messages, addMessage] = useMessages([])
const [newMessage, setNewMessage] = useState('') const [newMessage, setNewMessage] = useState('')
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(
bubbleHeight => {
if (newMessage.length > 0) { if (newMessage.length > 0) {
addMessage(newMessage) addMessage({
id: +new Date(),
text: newMessage,
height: bubbleHeight
})
setNewMessage('') setNewMessage('')
} }
}, [newMessage, messages]) },
[newMessage, messages]
)
const lastMessage = messages[messages.length - 1]
const dy = lastMessage ? lastMessage.height : 0
return ( return (
<div className="App"> <div className="App">
<Chat> <Chat>
<AnimatePresence> <AnimatePresence>
{messages.map(m => ( {messages.map(m => (
<Bubble key={m} id={m}> <Bubble key={m.id} id={m.id} dy={dy}>
{m} {m.text}
</Bubble> </Bubble>
))} ))}
</AnimatePresence> </AnimatePresence>

View file

@ -1,4 +1,5 @@
.bubble.input { .bubble.input,
.bubble.input .bubble-content {
transition: opacity 0.4s ease-in-out; transition: opacity 0.4s ease-in-out;
opacity: 1; opacity: 1;
border: none; border: none;

View file

@ -3,19 +3,23 @@ import './bubble-input.css'
const BubbleInput = ({ onChange, onSubmit, value }) => { const BubbleInput = ({ onChange, onSubmit, value }) => {
const refEditable = useRef() const refEditable = useRef()
const refContainer = useRef()
const [submitted, setSubmitted] = useState(false) const [submitted, setSubmitted] = useState(false)
const handleKeyDown = e => { const handleKeyDown = e => {
const { current: elContainer } = refContainer
const { current: elEditable } = refEditable
const { isComposing } = e.nativeEvent const { isComposing } = e.nativeEvent
if (e.key === 'Enter' && !isComposing) { if (e.key === 'Enter' && !isComposing) {
onSubmit && onSubmit() const height = elContainer.clientHeight
onSubmit && onSubmit(height)
e.preventDefault() e.preventDefault()
setSubmitted(true) setSubmitted(true)
setTimeout(() => { requestAnimationFrame(() => {
refEditable.current.focus() elEditable.focus()
refEditable.current.innerText = '' elEditable.innerText = ''
setSubmitted(false) setSubmitted(false)
}, 10) })
} }
} }
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
@ -29,16 +33,21 @@ const BubbleInput = ({ onChange, onSubmit, value }) => {
return ( return (
<div <div
ref={refEditable} ref={refContainer}
className={`bubble input ${value.length === 0 ? 'empty' : ''} ${ className={`bubble input ${value.length === 0 ? 'empty' : ''} ${
submitted ? 'submitted' : '' submitted ? 'submitted' : ''
}`} }`}
>
<div
ref={refEditable}
className="bubble-content"
contentEditable contentEditable
spellCheck="false" spellCheck="false"
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onInput={e => onChange(e.target.innerText)} onInput={e => onChange(e.target.innerText)}
/> />
</div>
) )
} }

View file

@ -1,20 +1,25 @@
.bubble { .bubble {
max-width: 600px;
}
.bubble-content {
max-width: 600px;
display: inline-block;
border-radius: 30px; border-radius: 30px;
padding: 12px 20px; padding: 12px 20px;
margin-top: 5px; margin-top: 5px;
margin-bottom: 5px; margin-bottom: 5px;
display: inline-block; hyphens: auto;
max-width: 600px;
} }
.bubble { .bubble-content {
margin-right: 25%; margin-right: 25%;
background-color: #eee; background-color: #eee;
position: relative; position: relative;
} }
.bubble:last-child:before, .bubble:last-child .bubble-content:before,
.bubble:nth-last-child(2):before { .bubble:nth-last-child(2) .bubble-content:before {
content: ''; content: '';
position: absolute; position: absolute;
z-index: 0; z-index: 0;
@ -26,8 +31,8 @@
border-bottom-right-radius: 20px; border-bottom-right-radius: 20px;
} }
.bubble:last-child:after, .bubble:last-child .bubble-content:after,
.bubble:nth-last-child(2):after { .bubble:nth-last-child(2) .bubble-content:after {
content: ''; content: '';
position: absolute; position: absolute;
z-index: 1; z-index: 1;

View file

@ -1,17 +1,38 @@
import React from 'react' import React from 'react'
import { motion } from 'framer-motion' import { motion, usePresence } from 'framer-motion'
import './bubble.css' import './bubble.css'
const Bubble = ({ id, children, sender }) => { const transition = {
type: 'spring',
stiffness: 500,
damping: 50,
default: {
duration: 1
}
}
const Bubble = ({ id, children, sender, dy }) => {
const [isPresent, safeToRemove] = usePresence()
const animations = {
layout: true,
initial: 'out',
style: {
position: 'static'
},
animate: 'in',
variants: {
in: { opacity: 1, translateY: 0 },
out: { opacity: 1, translateY: `${dy}px` }
},
exit: { opacity: 0, translateY: 0 },
onAnimationComplete: () => !isPresent && safeToRemove(),
transition
}
return ( return (
<motion.div <motion.div key={id} className="bubble" {...animations}>
key={id} <div className="bubble-content">{children}</div>
className="bubble"
initial={{ opacity: 1, translateY: 60 }}
animate={{ opacity: 1, translateY: 0 }}
exit={{ opacity: 0 }}
>
{children}
</motion.div> </motion.div>
) )
} }

View file

@ -7,4 +7,5 @@
align-items: flex-start; align-items: flex-start;
font-size: 32px; font-size: 32px;
position: relative;
} }