diff --git a/package.json b/package.json index 93d59a4fa6..966119d1eb 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "react-dom": "^16.9.0", "react-focus-lock": "^2.2.1", "react-resizable": "^1.10.1", + "react-transition-group": "^4.4.1", "resize-observer-polyfill": "^1.5.0", "sanitize-html": "^1.18.4", "text-encoding-utf-8": "^1.0.1", @@ -126,6 +127,7 @@ "@types/qrcode": "^1.3.4", "@types/react": "^16.9", "@types/react-dom": "^16.9.8", + "@types/react-transition-group": "^4.4.0", "@types/zxcvbn": "^4.4.0", "babel-eslint": "^10.0.3", "babel-jest": "^24.9.0", diff --git a/res/css/views/rooms/_RoomBreadcrumbs2.scss b/res/css/views/rooms/_RoomBreadcrumbs2.scss index 2db0fdca08..68cf7d7500 100644 --- a/res/css/views/rooms/_RoomBreadcrumbs2.scss +++ b/res/css/views/rooms/_RoomBreadcrumbs2.scss @@ -14,34 +14,32 @@ See the License for the specific language governing permissions and limitations under the License. */ -@keyframes breadcrumb-popin { - 0% { - // Ideally we'd use `width` instead of `opacity`, but we only - // have 16 nanoseconds to render the frame, and width is expensive. - opacity: 0; - transform: scale(0); - } - 100% { - opacity: 1; - transform: scale(1); - } -} - .mx_RoomBreadcrumbs2 { + width: 100%; + // Create a flexbox for the crumbs display: flex; flex-direction: row; align-items: flex-start; - width: 100%; .mx_RoomBreadcrumbs2_crumb { margin-right: 8px; width: 32px; + } - // React loves to add elements, so only target the one we want to animate - &:first-child { - animation: breadcrumb-popin 0.3s; - } + // These classes come from the CSSTransition component. There's many more classes we + // could care about, but this is all we worried about for now. The animation works by + // first triggering the enter state with the newest breadcrumb off screen (-40px) then + // sliding it into view. + &.mx_RoomBreadcrumbs2-enter { + margin-left: -40px; // 32px for the avatar, 8px for the margin + } + &.mx_RoomBreadcrumbs2-enter-active { + margin-left: 0; + + // Timing function is as-requested by design. + // NOTE: The transition time MUST match the value passed to CSSTransition! + transition: margin-left 300ms cubic-bezier(0.66, 0.02, 0.36, 1); } .mx_RoomBreadcrumbs2_placeholder { diff --git a/src/components/views/rooms/RoomBreadcrumbs2.tsx b/src/components/views/rooms/RoomBreadcrumbs2.tsx index 195757ccf0..197170018a 100644 --- a/src/components/views/rooms/RoomBreadcrumbs2.tsx +++ b/src/components/views/rooms/RoomBreadcrumbs2.tsx @@ -23,6 +23,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import Analytics from "../../../Analytics"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; +import { CSSTransition, TransitionGroup } from "react-transition-group"; /******************************************************************* * CAUTION * @@ -36,6 +37,14 @@ interface IProps { } interface IState { + // Both of these control the animation for the breadcrumbs. For details on the + // actual animation, see the CSS. + // + // doAnimation is to lie to the CSSTransition component (see onBreadcrumbsUpdate + // for info). skipFirst is used to try and reduce jerky animation - also see the + // breadcrumb update function for info on that. + doAnimation: boolean; + skipFirst: boolean; } export default class RoomBreadcrumbs2 extends React.PureComponent { @@ -44,6 +53,11 @@ export default class RoomBreadcrumbs2 extends React.PureComponent { if (!this.isMounted) return; - this.forceUpdate(); // we have no state, so this is the best we can do + + // We need to trick the CSSTransition component into updating, which means we need to + // tell it to not animate, then to animate a moment later. This causes two updates + // which means two renders. The skipFirst change is so that our don't-animate state + // doesn't show the breadcrumb we're about to reveal as it causes a visual jump/jerk. + // The second update, on the next available tick, causes the "enter" animation to start + // again and this time we want to show the newest breadcrumb because it'll be hidden + // off screen for the animation. + this.setState({doAnimation: false, skipFirst: true}); + setTimeout(() => this.setState({doAnimation: true, skipFirst: false}), 0); }; private viewRoom = (room: Room, index: number) => { @@ -77,14 +100,26 @@ export default class RoomBreadcrumbs2 extends React.PureComponent - {_t("No recently visited rooms")} + if (tiles.length > 0) { + // NOTE: The CSSTransition timeout MUST match the timeout in our CSS! + return ( + +
+ {tiles.slice(this.state.skipFirst ? 1 : 0)} +
+
+ ); + } else { + return ( +
+
+ {_t("No recently visited rooms")} +
); } - - return
{tiles}
; } } diff --git a/tsconfig.json b/tsconfig.json index 8a01ca335e..db040d1f31 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,8 @@ "types": [ "node", "react", - "flux" + "flux", + "react-transition-group" ] }, "include": [ diff --git a/yarn.lock b/yarn.lock index 333c5ccf20..32f8d3093e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -968,7 +968,7 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": version "7.10.2" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.10.2.tgz#d103f21f2602497d38348a32e008637d506db839" integrity sha512-6sF3uQw2ivImfVIl62RZ7MXhO2tap69WeWK57vAaimT6AZbE4FbqjdEJIN1UqoD6wI6B+1n9UiagafH1sxjOtg== @@ -1352,6 +1352,13 @@ dependencies: "@types/react" "*" +"@types/react-transition-group@^4.4.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.0.tgz#882839db465df1320e4753e6e9f70ca7e9b4d46d" + integrity sha512-/QfLHGpu+2fQOqQaXh8MG9q03bFENooTb/it4jr5kKaZlDQfWvjqWZg48AwzPVMBHlRuTRAY7hRHCEOXz5kV6w== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@^16.9": version "16.9.35" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.35.tgz#a0830d172e8aadd9bd41709ba2281a3124bbd368" @@ -2835,7 +2842,7 @@ cssstyle@^1.0.0: dependencies: cssom "0.3.x" -csstype@^2.2.0: +csstype@^2.2.0, csstype@^2.6.7: version "2.6.10" resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.10.tgz#e63af50e66d7c266edb6b32909cfd0aabe03928b" integrity sha512-D34BqZU4cIlMCY93rZHbrq9pjTAQJ3U8S8rfBqjwHxkGPThWFjzZDQpgMJY0QViLxth6ZKYiwFBo14RdN44U/w== @@ -3054,6 +3061,14 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-helpers@^5.0.1: + version "5.1.4" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.1.4.tgz#4609680ab5c79a45f2531441f1949b79d6587f4b" + integrity sha512-TjMyeVUvNEnOnhzs6uAn9Ya47GmMo3qq7m+Lr/3ON0Rs5kHvb8I+SQYjLUSYn7qhEm0QjW0yrBkvz9yOrwwz1A== + dependencies: + "@babel/runtime" "^7.8.7" + csstype "^2.6.7" + dom-serializer@0, dom-serializer@^0.2.1: version "0.2.2" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" @@ -7136,6 +7151,16 @@ react-test-renderer@^16.0.0-0, react-test-renderer@^16.9.0: react-is "^16.8.6" scheduler "^0.19.1" +react-transition-group@^4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9" + integrity sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw== + dependencies: + "@babel/runtime" "^7.5.5" + dom-helpers "^5.0.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react@^16.9.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"