feat(swipe-to-*): Gesture support and animation in thread list

Summary:
This diff does two things:
- It adds a new SwipeContainer that makes it easy to implement swipe gestures. This is built into listTabular, so you can create a list and define onSwipeLeft/Right to enable gestures.

- It adds support for basic add/remove animations to the thread list. This works by adding a CSS transition to `top` and also leaving removed rows around for a specified time. (these times need to match.) Pretty much just cloned the core idea from TimeoutTransitionGroup.

Test Plan: No tests yet

Reviewers: evan, juan

Reviewed By: evan

Differential Revision: https://phab.nylas.com/D2581
This commit is contained in:
Ben Gotow 2016-02-19 18:22:28 -08:00
parent fb4e806afe
commit 592374c0dc
19 changed files with 453 additions and 71 deletions

View file

@ -15,7 +15,7 @@ class ThreadTrashButton extends React.Component
render: =>
focusedMailboxPerspective = FocusedPerspectiveStore.current()
return false unless focusedMailboxPerspective?.canTrashThreads()
return false unless focusedMailboxPerspective.canTrashThreads()
<button className="btn btn-toolbar"
style={order: -106}

View file

@ -18,7 +18,7 @@ class ThreadBulkArchiveButton extends React.Component
render: ->
mailboxPerspective = FocusedPerspectiveStore.current()
return false unless mailboxPerspective?.canArchiveThreads()
return false unless mailboxPerspective.canArchiveThreads()
<button style={order:-107}
className="btn btn-toolbar"
@ -43,7 +43,7 @@ class ThreadBulkTrashButton extends React.Component
render: ->
mailboxPerspective = FocusedPerspectiveStore.current()
return false unless mailboxPerspective?.canTrashThreads()
return false unless mailboxPerspective.canTrashThreads()
<button style={order:-106}
className="btn btn-toolbar"

View file

@ -13,7 +13,7 @@ class ThreadArchiveQuickAction extends React.Component
mailboxPerspective = FocusedPerspectiveStore.current()
archive = null
if mailboxPerspective?.canArchiveThreads()
if mailboxPerspective.canArchiveThreads()
archive = <div key="archive"
title="Archive"
style={{ order: 100 }}
@ -42,7 +42,7 @@ class ThreadTrashQuickAction extends React.Component
mailboxPerspective = FocusedPerspectiveStore.current()
trash = null
if mailboxPerspective?.canTrashThreads()
if mailboxPerspective.canTrashThreads()
trash = <div key="remove"
title="Trash"
style={{ order: 110 }}

View file

@ -98,8 +98,23 @@ class ThreadList extends React.Component
</FluxContainer>
_threadPropsProvider: (item) ->
className: classNames
'unread': item.unread
props =
className: classNames
'unread': item.unread
perspective = FocusedPerspectiveStore.current()
account = AccountStore.accountForId(item.accountId)
finishedName = account.defaultFinishedCategory()?.name
if finishedName is 'trash' and perspective.canTrashThreads()
props.onSwipeRightClass = 'swipe-trash'
props.onSwipeRight = -> perspective.removeThreads([item])
else if finishedName in ['archive', 'all'] and perspective.canArchiveThreads()
props.onSwipeRightClass = 'swipe-archive'
props.onSwipeRight = -> perspective.removeThreads([item])
props
_threadMouseManipulateData: (event) ->
itemThreadId = @refs.list.itemIdAtPoint(event.clientX, event.clientY)

View file

@ -48,6 +48,80 @@
-webkit-font-smoothing: subpixel-antialiased;
}
.swipe-backing {
background-color: darken(@background-primary, 10%);
&::after {
color: fade(white, 90%);
padding-top: 45px;
text-align: center;
font-weight: 400;
font-size: @font-size-small;
position: absolute;
top: 0;
transform: translateX(0%);
width: 80px;
bottom: 0;
opacity: 0.8;
transition: opacity linear 150ms;
background-repeat: no-repeat;
background-position: 50% 35%;
background-size: 24px 24px;
}
&.swipe-trash {
transition: background-color linear 150ms;
background-color: mix(#ed304b, @background-primary, 75%);
&::after {
transition: left linear 150ms, transform linear 150ms;
content: "Trash";
left: 0;
background-image: url(../static/images/swipe/icon-swipe-trash@2x.png);
}
&.confirmed {
background-color: #ed304b;
&::after {
left: 100%;
transform: translateX(-100%);
opacity: 1;
}
}
}
&.swipe-archive {
transition: background-color linear 150ms;
background-color: mix(#6cd420, @background-primary, 75%);
&::after {
transition: left linear 150ms, transform linear 150ms;
content: "Archive";
left: 0;
background-image: url(../static/images/swipe/icon-swipe-archive@2x.png);
}
&.confirmed {
background-color: #6cd420;
&::after {
left: 100%;
transform: translateX(-100%);
opacity: 1;
}
}
}
&.swipe-snooze {
background-color: mix(#8d6be3, @background-primary, 75%);
&::after {
content: "Snooze";
left: 0;
background-image: url(../static/images/swipe/icon-swipe-snooze@2x.png);
}
&.confirmed {
background-color: #8d6be3;
&::after {
left: 100%;
transform: translateX(-100%);
opacity: 1;
}
}
}
}
.list-item {
background-color: darken(@background-primary, 2%);
border-bottom: 1px solid fade(@list-border, 60%);

View file

@ -1,9 +1,15 @@
{AccountStore, MailboxPerspective, TaskFactory, Category, Actions, DatabaseStore} = require 'nylas-exports'
{AccountStore,
MailboxPerspective,
TaskFactory,
Category,
Thread,
Actions,
DatabaseStore} = require 'nylas-exports'
describe 'MailboxPerspective', ->
beforeEach ->
spyOn(AccountStore, 'accountForId').andReturn {categoryIcon: -> 'icon'}
spyOn(AccountStore, 'accountForId').andReturn(AccountStore.accounts()[0])
@accountIds = ['a1', 'a2', 'a3']
@perspective = new MailboxPerspective(@accountIds)
@ -51,40 +57,50 @@ describe 'MailboxPerspective', ->
describe 'removeThreads', ->
beforeEach ->
@threads = ['t1', 't2']
@taskArgs = {threads: @threads, categories: @categories}
@threads = [new Thread(id:'t1'), new Thread(id: 't2')]
spyOn(Actions, 'queueTasks')
spyOn(DatabaseStore, 'modelify').andReturn then: (cb) => cb(@threads)
it 'moves the threads to finished category if in inbox', ->
spyOn(@perspective, 'isInbox').andReturn true
spyOn(@perspective, 'canTrashThreads').andReturn true
spyOn(@perspective, 'canArchiveThreads').andReturn true
spyOn(TaskFactory, 'tasksForRemovingCategories')
@categories = [
new Category(name: 'inbox', accountId: 'a1')
new Category(name: 'inbox', accountId: 'a2')
new Category(name: 'inbox', accountId: 'a2')
]
@perspective = MailboxPerspective.forCategories(@categories)
@perspective.removeThreads(@threads)
@taskArgs.moveToFinishedCategory = true
expect(TaskFactory.tasksForRemovingCategories).toHaveBeenCalledWith(@taskArgs)
expect(TaskFactory.tasksForRemovingCategories).toHaveBeenCalledWith({
threads: @threads,
moveToFinishedCategory: true,
categories: @categories
})
it 'moves threads to inbox if in trash', ->
spyOn(@perspective, 'isInbox').andReturn false
spyOn(@perspective, 'canTrashThreads').andReturn false
spyOn(@perspective, 'canArchiveThreads').andReturn true
spyOn(TaskFactory, 'tasksForMovingToInbox')
@categories = [
new Category(name: 'trash', accountId: 'a1')
new Category(name: 'trash', accountId: 'a2')
new Category(name: 'trash', accountId: 'a2')
]
@perspective = MailboxPerspective.forCategories(@categories)
@perspective.removeThreads(@threads)
expect(TaskFactory.tasksForMovingToInbox).toHaveBeenCalledWith({threads: @threads, fromPerspective: @perspective})
expect(TaskFactory.tasksForMovingToInbox).toHaveBeenCalledWith({
threads: @threads,
fromPerspective: @perspective
})
it 'removes categories if the current perspective does not correspond to archive or sent', ->
spyOn(@perspective, 'isInbox').andReturn false
spyOn(@perspective, 'canTrashThreads').andReturn true
spyOn(@perspective, 'canArchiveThreads').andReturn true
spyOn(TaskFactory, 'tasksForRemovingCategories')
@categories = [
new Category(displayName: 'c1', accountId: 'a1')
new Category(displayName: 'c2', accountId: 'a2')
new Category(displayName: 'c3', accountId: 'a2')
]
@perspective = MailboxPerspective.forCategories(@categories)
@perspective.removeThreads(@threads)
@taskArgs.moveToFinishedCategory = false
expect(TaskFactory.tasksForRemovingCategories).toHaveBeenCalledWith(@taskArgs)
it 'does nothing otherwise', ->
spyOn(@perspective, 'isInbox').andReturn false
spyOn(@perspective, 'canTrashThreads').andReturn true
spyOn(@perspective, 'canArchiveThreads').andReturn false
@perspective.removeThreads(@threads)
expect(Actions.queueTasks).not.toHaveBeenCalled()
expect(TaskFactory.tasksForRemovingCategories).toHaveBeenCalledWith({
threads: @threads,
moveToFinishedCategory: false,
categories: @categories
})

View file

@ -162,6 +162,12 @@ class NylasWindow
@browserWindow.on 'closed', =>
global.application.windowManager.removeWindow(this)
@browserWindow.on 'scroll-touch-begin', =>
@browserWindow.webContents.send('scroll-touch-begin')
@browserWindow.on 'scroll-touch-end', =>
@browserWindow.webContents.send('scroll-touch-end')
@browserWindow.on 'focus', =>
@browserWindow.webContents.send('browser-window-focus')

View file

@ -1,5 +1,6 @@
_ = require 'underscore'
React = require 'react/addons'
SwipeContainer = require './swipe-container'
{Utils} = require 'nylas-exports'
class ListTabularItem extends React.Component
@ -32,9 +33,11 @@ class ListTabularItem extends React.Component
# We only do it if the item prop has changed.
@_columnCache ?= @_columns()
<div {...props} className={className} onClick={@_onClick} style={position:'absolute', top: @props.metrics.top, width:'100%', height:@props.metrics.height}>
{@_columnCache}
</div>
<SwipeContainer {...props} onClick={@_onClick} style={position:'absolute', top: @props.metrics.top, width:'100%', height:@props.metrics.height}>
<div className={className} style={height:@props.metrics.height}>
{@_columnCache}
</div>
</SwipeContainer>
_columns: =>
names = {}

View file

@ -35,6 +35,7 @@ class ListTabular extends React.Component
componentWillUnmount: =>
window.removeEventListener('resize', @onWindowResize, true)
window.clearTimeout(@_cleanupAnimationTimeout) if @_cleanupAnimationTimeout
@_unlisten?()
componentWillReceiveProps: (nextProps) =>
@ -53,15 +54,39 @@ class ListTabular extends React.Component
dataSource ?= @props.dataSource
items = {}
animatingOut = {}
[start..end].forEach (idx) =>
items[idx] = dataSource.get(idx)
# If we have a previous state, and the previous range matches the new range,
# (eg: we're not scrolling), identify removed items. We'll render them in one
# last time but not allocate height to them. This allows us to animate them
# being covered by other items, not just disappearing when others start to slide up.
if @state and start is @state.renderedRangeStart
nextIds = _.pluck(_.values(items), 'id')
animatingOut = {}
# Keep items which are still animating out and are still not in the set
for recordIdx, record of @state.animatingOut
if Date.now() < record.end and not (record.item.id in nextIds)
animatingOut[recordIdx] = record
# Add items which are no longer found in the set
for previousIdx, previousItem of @state.items
continue if !previousItem or previousItem.id in nextIds
animatingOut[previousIdx] =
item: previousItem
idx: previousIdx
end: Date.now() + 125
renderedRangeStart: start
renderedRangeEnd: end
count: dataSource.count()
loaded: dataSource.loaded()
empty: dataSource.empty()
items: items
animatingOut: animatingOut
componentDidUpdate: (prevProps, prevState) =>
# If our view has been swapped out for an entirely different one,
@ -73,6 +98,23 @@ class ListTabular extends React.Component
@updateRangeState()
@updateRangeStateFiring = false
unless @_cleanupAnimationTimeout
@_cleanupAnimationTimeout = window.setTimeout(@onCleanupAnimatingItems, 50)
onCleanupAnimatingItems: =>
@_cleanupAnimationTimeout = null
nextAnimatingOut = {}
for idx, record of @state.animatingOut
if Date.now() < record.end
nextAnimatingOut[idx] = record
if Object.keys(nextAnimatingOut).length < Object.keys(@state.animatingOut).length
@setState(animatingOut: nextAnimatingOut)
if Object.keys(nextAnimatingOut).length > 0
@_cleanupAnimationTimeout = window.setTimeout(@onCleanupAnimatingItems, 50)
onWindowResize: =>
@_onWindowResize ?= _.debounce(@updateRangeState, 50)
@_onWindowResize()
@ -132,19 +174,29 @@ class ListTabular extends React.Component
</div>
_rows: =>
[@state.renderedRangeStart..@state.renderedRangeEnd-1].map (idx) =>
item = @state.items[idx]
return false unless item
# The ordering of the results array is important. We want current rows to
# slide over rows which are animating out, so we need to render them last.
results = []
for idx, record of @state.animatingOut
results.push @_rowForItem(record.item, idx / 1)
<ListTabularItem key={item.id ? idx}
item={item}
itemProps={@props.itemPropsProvider?(item, idx) ? {}}
metrics={top: idx * @props.itemHeight, height: @props.itemHeight}
columns={@props.columns}
onSelect={@props.onSelect}
onClick={@props.onClick}
onReorder={@props.onReorder}
onDoubleClick={@props.onDoubleClick} />
[@state.renderedRangeStart..@state.renderedRangeEnd].forEach (idx) =>
if @state.items[idx]
results.push @_rowForItem(@state.items[idx], idx)
results
_rowForItem: (item, idx) =>
return false unless item
<ListTabularItem key={item.id ? idx}
item={item}
itemProps={@props.itemPropsProvider?(item, idx) ? {}}
metrics={top: idx * @props.itemHeight, height: @props.itemHeight}
columns={@props.columns}
onSelect={@props.onSelect}
onClick={@props.onClick}
onReorder={@props.onReorder}
onDoubleClick={@props.onDoubleClick} />
# Public: Scroll to the DOM node provided.
#

View file

@ -209,7 +209,7 @@ class MultiselectList extends React.Component
# Public Methods
itemIdAtPoint: (x, y) ->
item = document.elementFromPoint(event.clientX, event.clientY).closest('.list-item')
item = document.elementFromPoint(event.clientX, event.clientY).closest('[data-item-id]')
return null unless item
return item.dataset.itemId

View file

@ -0,0 +1,209 @@
import React, {Component, PropTypes} from 'react';
import _ from 'underscore';
const Phase = {
// No wheel events received yet, container is inactive.
None: 'none',
// Wheel events received
GestureStarting: 'gesture-starting',
// Wheel events received and we are stopping event propagation.
GestureConfirmed: 'gesture-confirmed',
// Fingers lifted, we are animating to a final state.
Settling: 'settling',
}
export default class SwipeContainer extends Component {
static displayName = 'SwipeContainer';
static propTypes = {
children: PropTypes.object.isRequired,
onSwipeLeft: React.PropTypes.func,
onSwipeLeftClass: React.PropTypes.string,
onSwipeRight: React.PropTypes.func,
onSwipeRightClass: React.PropTypes.string,
};
constructor(props) {
super(props);
this.tracking = false;
this.phase = Phase.None;
this.fired = false;
this.state = {
fullDistance: 'unknown',
velocity: 0,
currentX: 0,
targetX: 0,
};
}
componentDidMount() {
window.addEventListener('scroll-touch-begin', this._onScrollTouchBegin);
window.addEventListener('scroll-touch-end', this._onScrollTouchEnd);
}
// componentWillReceiveProps(nextProps) {
// if (nextProps.children !== this.props.children) {
// this.setState({currentX: 0, velocity: 0, targetX: 0, settling: false});
// }
// }
componentDidUpdate() {
if (this.phase === Phase.Settling) {
window.requestAnimationFrame(()=> {
if (this.phase === Phase.Settling) {
this._settle();
}
});
}
}
componentWillUnmount() {
this.phase = Phase.None;
window.removeEventListener('scroll-touch-begin', this._onScrollTouchBegin);
window.removeEventListener('scroll-touch-end', this._onScrollTouchEnd);
}
_isEnabled = ()=> {
return (this.props.onSwipeLeft || this.props.onSwipeRight);
}
_onWheel = (e)=> {
if ((this.tracking === false) || !this._isEnabled()) {
return;
}
const velocity = e.deltaX / 3;
const velocityConfirmsGesture = Math.abs(velocity) > 3;
if (this.phase === Phase.None) {
this.phase = Phase.GestureStarting;
}
if (velocityConfirmsGesture || (this.phase === Phase.Settling)) {
this.phase = Phase.GestureConfirmed;
}
if (this.phase === Phase.GestureConfirmed) {
e.preventDefault();
}
let {fullDistance, thresholdDistance} = this.state;
if (fullDistance === 'unknown') {
fullDistance = React.findDOMNode(this).clientWidth;
thresholdDistance = 110;
}
const currentX = Math.max(-fullDistance, Math.min(fullDistance, this.state.currentX + velocity));
const estimatedSettleX = currentX + velocity * 8;
let targetX = 0;
if (this.props.onSwipeLeft && (estimatedSettleX > thresholdDistance)) {
targetX = fullDistance;
}
if (this.props.onSwipeRight && (estimatedSettleX < -thresholdDistance)) {
targetX = -fullDistance;
}
this.setState({thresholdDistance, fullDistance, velocity, currentX, targetX})
}
_onScrollTouchBegin = ()=> {
this.tracking = true;
}
_onScrollTouchEnd = ()=> {
this.tracking = false;
if (this.phase !== Phase.None) {
this.phase = Phase.Settling;
this.fired = false;
this._settle();
}
}
_settle() {
const {currentX, targetX} = this.state;
let {velocity} = this.state;
let step = 0;
let shouldFire = false;
let shouldFinish = false;
if (targetX === 0) {
// settle
step = (targetX - currentX) / 6.0;
shouldFinish = (Math.abs(step) < 0.05);
} else {
// accelerate offscreen
if (Math.abs(velocity) < Math.abs((targetX - currentX) / 48.0)) {
velocity = (targetX - currentX) / 48.0;
} else {
velocity = velocity * 1.08;
}
step = velocity;
const fraction = Math.abs(currentX) / Math.abs(targetX);
shouldFire = ((fraction >= 0.8) && (!this.fired));
shouldFinish = (fraction >= 1.0);
}
if (shouldFire) {
this.fired = true;
if (targetX < 0) {
this.props.onSwipeRight();
}
if (targetX > 0) {
this.props.onSwipeLeft();
}
}
if (shouldFinish) {
this.phase = Phase.None;
this.setState({
velocity: 0,
currentX: targetX,
targetX: targetX,
thresholdDistance: 'unknown',
fullDistance: 'unknown',
});
} else {
this.phase = Phase.Settling;
this.setState({
velocity: velocity,
currentX: currentX + step,
});
}
}
render() {
const {currentX, targetX} = this.state;
const otherProps = _.omit(this.props, _.keys(this.constructor.propTypes));
const backingStyles = {top: 0, bottom: 0, position: 'absolute'};
let backingClass = 'swipe-backing';
if (currentX > 0) {
backingClass += ' ' + this.props.onSwipeLeftClass;
backingStyles.right = 0;
backingStyles.width = currentX + 1;
if (targetX > 0) {
backingClass += ' confirmed';
}
} else if (currentX < 0) {
backingClass += ' ' + this.props.onSwipeRightClass;
backingStyles.left = 0;
backingStyles.width = -currentX + 1;
if (targetX < 0) {
backingClass += ' confirmed';
}
}
return (
<div onWheel={this._onWheel} {...otherProps}>
<div style={backingStyles} className={backingClass}></div>
<div style={{transform: 'translate3d(' + -currentX + 'px, 0, 0)'}}>
{this.props.children}
</div>
</div>
);
}
}

View file

@ -17,6 +17,7 @@ class NylasComponentKit
@load "Popover", 'popover'
@load "Flexbox", 'flexbox'
@load "RetinaImg", 'retina-img'
@load "SwipeContainer", 'swipe-container'
@load "FluxContainer", 'flux-container'
@load "ListTabular", 'list-tabular'
@load "DraggableImg", 'draggable-img'

View file

@ -210,6 +210,8 @@ class StarredMailboxPerspective extends MailboxPerspective
Actions.queueTask(task)
removeThreads: (threadsOrIds) =>
unless threadsOrIds instanceof Array
throw new Error("removeThreads: you must pass an array of threads or thread ids")
task = TaskFactory.taskForInvertingStarred(threads: threadsOrIds)
Actions.queueTask(task)
@ -292,7 +294,7 @@ class CategoryMailboxPerspective extends MailboxPerspective
canTrashThreads: =>
for cat in @_categories
return false if cat.name in ["trash"]
return false if cat.name in ["trash", "sent"]
super
receiveThreads: (threadsOrIds) =>
@ -309,30 +311,23 @@ class CategoryMailboxPerspective extends MailboxPerspective
Actions.queueTasks(tasks)
removeThreads: (threadsOrIds) =>
unless threadsOrIds instanceof Array
throw new Error("removeThreads: you must pass an array of threads or thread ids")
DatabaseStore.modelify(Thread, threadsOrIds).then (threads) =>
isTrash = not @canTrashThreads()
isNotArchiveOrSent = @canArchiveThreads()
isFinishedCategory = _.any @_categories, (cat) ->
cat.name in ['trash', 'archive', 'all']
tasks = null
categories = @categories()
if @isInbox()
tasks = TaskFactory.tasksForRemovingCategories({
threads,
categories,
moveToFinishedCategory: true
})
else if isTrash
tasks = TaskFactory.tasksForMovingToInbox({threads, fromPerspective: @})
else if isNotArchiveOrSent
tasks = TaskFactory.tasksForRemovingCategories({
threads,
categories,
moveToFinishedCategory: false
})
if isFinishedCategory
Actions.queueTasks(TaskFactory.tasksForMovingToInbox({
threads: threads,
fromPerspective: @
}))
else
return
Actions.queueTasks(tasks)
Actions.queueTasks(TaskFactory.tasksForRemovingCategories({
threads: threads,
categories: @categories(),
moveToFinishedCategory: @isInbox()
}))
module.exports = MailboxPerspective

View file

@ -47,6 +47,12 @@ class WindowEventHandler
activeElement = workspaceElement
NylasEnv.commands.dispatch(activeElement, command, args[0])
@subscribe ipcRenderer, 'scroll-touch-begin', ->
window.dispatchEvent(new Event('scroll-touch-begin'))
@subscribe ipcRenderer, 'scroll-touch-end', ->
window.dispatchEvent(new Event('scroll-touch-end'))
@subscribe $(window), 'beforeunload', =>
# Don't hide the window here if we don't want the renderer process to be
# throttled in case more work needs to be done before closing

View file

@ -78,6 +78,10 @@
.list-container {
.list-rows > div {
// Note: This allows rows to be animated in and out!
transition: top ease-out 120ms;
}
.list-item {
font-size: @font-size-base;
color: @text-color;

View file

@ -51,6 +51,7 @@
height: 100%;
width: 100%;
overflow-y: scroll;
overflow-x: hidden;
z-index: 1; // Important so that content does not repaint with container
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB