2 * Copyright (C) 2014-2015 Canonical, Ltd.
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation; version 3.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License
14 * along with this program. If not, see <http://www.gnu.org/licenses/>.
18 import Ubuntu.Components 0.1
19 import Ubuntu.Gestures 0.1
20 import Unity.Application 0.1
22 import "../Components"
30 // Controls to be set from outside
31 property int dragAreaWidth
32 property real maximizedAppTopMargin
33 property bool interactive
34 property alias beingResized: spreadView.beingResized
36 property bool spreadEnabled: true // If false, animations and right edge will be disabled
38 property real inverseProgress: 0 // This is the progress for left edge drags, in pixels.
39 property bool keepDashRunning: true
40 property bool suspended: false
41 property int shellOrientationAngle: 0
42 property int shellOrientation
43 property int shellPrimaryOrientation
44 property int nativeOrientation
45 property real nativeWidth
46 property real nativeHeight
47 function updateFocusedAppOrientation() {
48 var mainStageAppIndex = priv.indexOf(priv.mainStageAppId);
49 if (mainStageAppIndex >= 0 && mainStageAppIndex < spreadRepeater.count) {
50 spreadRepeater.itemAt(mainStageAppIndex).matchShellOrientation();
53 for (var i = 0; i < spreadRepeater.count; ++i) {
55 if (i === mainStageAppIndex) {
59 var spreadDelegate = spreadRepeater.itemAt(i);
61 var delta = spreadDelegate.appWindowOrientationAngle - root.shellOrientationAngle;
62 if (delta < 0) { delta += 360; }
65 var supportedOrientations = spreadDelegate.application.supportedOrientations;
66 if (supportedOrientations === Qt.PrimaryOrientation) {
67 supportedOrientations = spreadDelegate.shellPrimaryOrientation;
70 if (delta === 180 && (supportedOrientations & spreadDelegate.shellOrientation)) {
71 spreadDelegate.matchShellOrientation();
75 function updateFocusedAppOrientationAnimated() {
76 var mainStageAppIndex = priv.indexOf(priv.mainStageAppId);
77 if (mainStageAppIndex >= 0 && mainStageAppIndex < spreadRepeater.count) {
78 spreadRepeater.itemAt(mainStageAppIndex).animateToShellOrientation();
81 if (priv.sideStageAppId) {
82 var sideStageAppIndex = priv.indexOf(priv.sideStageAppId);
83 if (sideStageAppIndex >= 0 && sideStageAppIndex < spreadRepeater.count) {
84 spreadRepeater.itemAt(sideStageAppIndex).matchShellOrientation();
89 // To be read from outside
90 property var mainApp: null
91 property int mainAppWindowOrientationAngle: 0
92 readonly property bool orientationChangesEnabled: priv.mainAppOrientationChangesEnabled
95 spreadView.selectedIndex = -1;
97 spreadView.contentX = -spreadView.shift;
100 onShellOrientationChanged: {
101 if (shellOrientation == Qt.PortraitOrientation || shellOrientation == Qt.InvertedPortraitOrientation) {
102 ApplicationManager.focusApplication(priv.mainStageAppId);
103 priv.sideStageAppId = "";
107 onInverseProgressChanged: {
108 // This can't be a simple binding because that would be triggered after this handler
109 // while we need it active before doing the anition left/right
110 spreadView.animateX = (inverseProgress == 0)
111 if (inverseProgress == 0 && priv.oldInverseProgress > 0) {
112 // left edge drag released. Minimum distance is given by design.
113 if (priv.oldInverseProgress > units.gu(22)) {
114 ApplicationManager.requestFocusApplication("unity8-dash");
117 priv.oldInverseProgress = inverseProgress;
122 objectName: "stagesPriv"
124 property string focusedAppId: ApplicationManager.focusedApplicationId
125 readonly property var focusedAppDelegate: {
126 var index = indexOf(focusedAppId);
127 return index >= 0 && index < spreadRepeater.count ? spreadRepeater.itemAt(index) : null
130 property string oldFocusedAppId: ""
131 property bool mainAppOrientationChangesEnabled: false
133 property real landscapeHeight: root.nativeOrientation == Qt.LandscapeOrientation ?
134 root.nativeHeight : root.nativeWidth
136 property bool shellIsLandscape: root.shellOrientation === Qt.LandscapeOrientation
137 || root.shellOrientation === Qt.InvertedLandscapeOrientation
139 property string mainStageAppId
140 property string sideStageAppId
142 // For convenience, keep properties of the first two apps in the model
143 property string appId0
144 property string appId1
146 property int oldInverseProgress: 0
148 onFocusedAppIdChanged: {
149 if (priv.focusedAppId.length > 0) {
150 var focusedApp = ApplicationManager.findApplication(focusedAppId);
151 if (focusedApp.stage == ApplicationInfoInterface.SideStage) {
152 priv.sideStageAppId = focusedAppId;
154 priv.mainStageAppId = focusedAppId;
155 root.mainApp = focusedApp;
159 appId0 = ApplicationManager.count >= 1 ? ApplicationManager.get(0).appId : "";
160 appId1 = ApplicationManager.count > 1 ? ApplicationManager.get(1).appId : "";
163 onFocusedAppDelegateChanged: {
164 if (focusedAppDelegate) {
165 focusedAppDelegate.focus = true;
169 function indexOf(appId) {
170 for (var i = 0; i < ApplicationManager.count; i++) {
171 if (ApplicationManager.get(i).appId == appId) {
178 function evaluateOneWayFlick(gesturePoints) {
179 // Need to have at least 3 points to recognize it as a flick
180 if (gesturePoints.length < 3) {
183 // Need to have a movement of at least 2 grid units to recognize it as a flick
184 if (Math.abs(gesturePoints[gesturePoints.length - 1] - gesturePoints[0]) < units.gu(2)) {
188 var oneWayFlick = true;
189 var smallestX = gesturePoints[0];
190 var leftWards = gesturePoints[1] < gesturePoints[0];
191 for (var i = 1; i < gesturePoints.length; i++) {
192 if ((leftWards && gesturePoints[i] >= smallestX)
193 || (!leftWards && gesturePoints[i] <= smallestX)) {
197 smallestX = gesturePoints[i];
204 target: ApplicationManager
206 if (spreadView.interactive) {
207 spreadView.snapTo(priv.indexOf(appId));
209 ApplicationManager.focusApplication(appId);
213 onApplicationAdded: {
214 if (spreadView.phase == 2) {
215 spreadView.snapTo(ApplicationManager.count - 1);
217 spreadView.phase = 0;
218 spreadView.contentX = -spreadView.shift;
219 ApplicationManager.focusApplication(appId);
223 onApplicationRemoved: {
224 if (priv.mainStageAppId == appId) {
225 ApplicationManager.focusApplication("unity8-dash")
227 if (priv.sideStageAppId == appId) {
228 priv.sideStageAppId = "";
231 if (ApplicationManager.count == 0) {
232 spreadView.phase = 0;
233 spreadView.contentX = -spreadView.shift;
234 } else if (spreadView.closingIndex == -1) {
235 // Unless we're closing the app ourselves in the spread,
236 // lets make sure the spread doesn't mess up by the changing app list.
237 spreadView.phase = 0;
238 spreadView.contentX = -spreadView.shift;
239 ApplicationManager.focusApplication(ApplicationManager.get(0).appId);
246 objectName: "spreadView"
248 interactive: (spreadDragArea.dragging || phase > 1) && draggedDelegateCount === 0
249 contentWidth: spreadRow.width - shift
252 property int tileDistance: units.gu(20)
253 property int sideStageWidth: units.gu(40)
254 property bool sideStageVisible: priv.sideStageAppId
256 // This indicates when the spreadView is active. That means, all the animations
257 // are activated and tiles need to line up for the spread.
258 readonly property bool active: shiftedContentX > 0 || spreadDragArea.dragging
260 // The flickable needs to fill the screen in order to get touch events all over.
261 // However, we don't want to the user to be able to scroll back all the way. For
262 // that, the beginning of the gesture starts with a negative value for contentX
263 // so the flickable wants to pull it into the view already. "shift" tunes the
264 // distance where to "lock" the content.
265 readonly property real shift: width / 2
266 readonly property real shiftedContentX: contentX + shift
268 // Phase of the animation:
269 // 0: Starting from right edge, a new app (index 1) comes in from the right
270 // 1: The app has reached the first snap position.
271 // 2: The list is dragged further and snaps into the spread view when entering phase 2
274 readonly property int phase0Width: sideStageWidth
275 readonly property int phase1Width: sideStageWidth
277 // Those markers mark the various positions in the spread (ratio to screen width from right to left):
278 // 0 - 1: following finger, snap back to the beginning on release
279 readonly property real positionMarker1: 0.2
280 // 1 - 2: curved snapping movement, snap to nextInStack on release
281 readonly property real positionMarker2: sideStageWidth / spreadView.width
282 // 2 - 3: movement follows finger, snaps to phase 2 (full spread) on release
283 readonly property real positionMarker3: 0.6
284 // passing 3, we detach movement from the finger and snap to phase 2 (full spread)
285 readonly property real positionMarker4: 0.8
287 readonly property int startSnapPosition: phase0Width * 0.5
288 readonly property int endSnapPosition: phase0Width * 0.75
289 readonly property real snapPosition: 0.75
291 property int selectedIndex: -1
292 property int draggedDelegateCount: 0
293 property int closingIndex: -1
295 // FIXME: Workaround Flickable's not keepping its contentX still when resized
296 onContentXChanged: { forceItToRemainStillIfBeingResized(); }
297 onShiftChanged: { forceItToRemainStillIfBeingResized(); }
298 function forceItToRemainStillIfBeingResized() {
299 if (root.beingResized && contentX != -shift) {
304 property bool animateX: true
305 property bool beingResized: false
306 onBeingResizedChanged: {
308 // Brace yourselves for impact!
315 property bool sideStageDragging: sideStageDragHandle.dragging
316 property real sideStageDragProgress: sideStageDragHandle.progress
318 onSideStageDragProgressChanged: {
319 if (sideStageDragProgress == 1) {
320 ApplicationManager.focusApplication(priv.mainStageAppId);
321 priv.sideStageAppId = "";
325 // In case the ApplicationManager already holds an app when starting up we're missing animations
326 // Make sure we end up in the same state
327 Component.onCompleted: {
328 spreadView.contentX = -spreadView.shift
331 property int nextInStack: {
334 if (ApplicationManager.count > 1) {
338 case "mainAndOverlay":
339 if (ApplicationManager.count <= 2) {
342 if (priv.appId0 == priv.mainStageAppId || priv.appId0 == priv.sideStageAppId) {
343 if (priv.appId1 == priv.mainStageAppId || priv.appId1 == priv.sideStageAppId) {
354 property int nextZInStack: indexToZIndex(nextInStack)
363 State { // Side Stage only in overlay mode
366 State { // Main Stage and Side Stage in overlay mode
367 name: "mainAndOverlay"
369 State { // Main Stage and Side Stage in split mode
374 if (priv.mainStageAppId && !priv.sideStageAppId) {
377 if (!priv.mainStageAppId && priv.sideStageAppId) {
380 if (priv.mainStageAppId && priv.sideStageAppId) {
381 return "mainAndOverlay";
386 onShiftedContentXChanged: {
387 if (root.beingResized) {
388 // Flickabe.contentX wiggles during resizes. Don't react to it.
391 if (spreadView.phase == 0 && spreadView.shiftedContentX > spreadView.width * spreadView.positionMarker2) {
392 spreadView.phase = 1;
393 } else if (spreadView.phase == 1 && spreadView.shiftedContentX > spreadView.width * spreadView.positionMarker4) {
394 spreadView.phase = 2;
395 } else if (spreadView.phase == 1 && spreadView.shiftedContentX < spreadView.width * spreadView.positionMarker2) {
396 spreadView.phase = 0;
401 if (shiftedContentX < phase0Width) {
402 snapAnimation.targetContentX = -shift;
403 snapAnimation.start();
404 } else if (shiftedContentX < phase1Width) {
407 // Add 1 pixel to make sure we definitely hit positionMarker4 even with rounding errors of the animation.
408 snapAnimation.targetContentX = spreadView.width * spreadView.positionMarker4 + 1 - shift;
409 snapAnimation.start();
412 function snapTo(index) {
413 spreadView.selectedIndex = index;
414 snapAnimation.targetContentX = -shift;
415 snapAnimation.start();
418 // We need to shuffle z ordering a bit in order to keep side stage apps above main stage apps.
419 // We don't want to really reorder them in the model because that allows us to keep track
420 // of the last focused order.
421 function indexToZIndex(index) {
422 var app = ApplicationManager.get(index);
427 var active = app.appId == priv.mainStageAppId || app.appId == priv.sideStageAppId;
428 if (active && app.stage == ApplicationInfoInterface.MainStage) {
429 // if this app is active, and its the MainStage, always put it to index 0
432 if (active && app.stage == ApplicationInfoInterface.SideStage) {
433 if (!priv.mainStageAppId) {
434 // Only have SS apps running. Put the active one at 0
438 // Precondition now: There's an active MS app and this is SS app:
439 if (spreadView.nextInStack >= 0 && ApplicationManager.get(spreadView.nextInStack).stage == ApplicationInfoInterface.MainStage) {
440 // If the next app coming from the right is a MS app, we need to elevate this SS ap above it.
441 // Put it to at least level 2, or higher if there's more apps coming in before this one.
442 return Math.max(index, 2);
444 // if this is no next app to come in from the right, place this one at index 1, just on top the active MS app.
448 if (index <= 2 && app.stage == ApplicationInfoInterface.MainStage && priv.sideStageAppId) {
449 // Ok, this is an inactive MS app. If there's an active SS app around, we need to place this one
450 // in between the active MS app and the active SS app, so that it comes in from there when dragging from the right.
451 // If there's now active SS app, just leave it where it is.
452 return priv.indexOf(priv.sideStageAppId) < index ? index - 1 : index;
454 if (index == spreadView.nextInStack && app.stage == ApplicationInfoInterface.SideStage) {
455 // This is a SS app and the next one to come in from the right:
456 if (priv.sideStageAppId && priv.mainStageAppId) {
457 // If there's both, an active MS and an active SS app, put this one right on top of that
460 // Or if there's only one other active app, put it on top of that.
461 // The case that there isn't any other active app is already handled above.
464 if (index == 2 && spreadView.nextInStack == 1 && priv.sideStageAppId) {
465 // If its index 2 but not the next one to come in, it means
466 // we've pulled another one down to index 2. Move this one up to 2 instead.
469 // don't touch all others... (mostly index > 3 + simple cases where the above doesn't shuffle much)
473 SequentialAnimation {
475 property int targetContentX: -spreadView.shift
477 UbuntuNumberAnimation {
480 to: snapAnimation.targetContentX
481 duration: UbuntuAnimation.FastDuration
486 if (spreadView.selectedIndex >= 0) {
487 var newIndex = spreadView.selectedIndex;
488 spreadView.selectedIndex = -1;
489 ApplicationManager.focusApplication(ApplicationManager.get(newIndex).appId);
490 spreadView.phase = 0;
491 spreadView.contentX = -spreadView.shift;
499 x: spreadView.contentX
500 width: spreadView.width + Math.max(spreadView.width, ApplicationManager.count * spreadView.tileDistance)
504 spreadView.snapTo(0);
508 id: sideStageBackground
510 width: spreadView.sideStageWidth * (1 - sideStageDragHandle.progress)
511 height: priv.landscapeHeight
512 x: spreadView.width - width
513 z: spreadView.indexToZIndex(priv.indexOf(priv.sideStageAppId))
514 opacity: spreadView.phase == 0 ? 1 : 0
515 Behavior on opacity { UbuntuNumberAnimation {} }
519 id: sideStageDragHandle
520 anchors.right: sideStageBackground.left
521 anchors.top: sideStageBackground.top
523 height: priv.landscapeHeight
524 z: sideStageBackground.z
525 opacity: spreadView.phase <= 0 && spreadView.sideStageVisible ? 1 : 0
526 property real progress: 0
527 property bool dragging: false
529 Behavior on opacity { UbuntuNumberAnimation {} }
533 onSideStageVisibleChanged: {
534 if (spreadView.sideStageVisible) {
535 sideStageDragHandle.progress = 0;
541 anchors.centerIn: parent
542 width: sideStageDragHandleMouseArea.pressed ? parent.width * 2 : parent.width
543 height: parent.height
544 source: "graphics/sidestage_handle@20.png"
545 Behavior on width { UbuntuNumberAnimation {} }
549 id: sideStageDragHandleMouseArea
551 enabled: spreadView.shiftedContentX == 0
553 property var gesturePoints: new Array()
554 property real totalDiff
560 sideStageDragHandle.progress = 0;
561 sideStageDragHandle.dragging = true;
564 totalDiff += mouseX - startX;
565 if (priv.mainStageAppId) {
566 sideStageDragHandle.progress = Math.max(0, totalDiff / spreadView.sideStageWidth);
568 gesturePoints.push(mouseX);
571 if (priv.mainStageAppId) {
572 var oneWayFlick = priv.evaluateOneWayFlick(gesturePoints);
573 sideStageDragSnapAnimation.to = sideStageDragHandle.progress > 0.5 || oneWayFlick ? 1 : 0;
574 sideStageDragSnapAnimation.start();
576 sideStageDragHandle.dragging = false;
580 UbuntuNumberAnimation {
581 id: sideStageDragSnapAnimation
582 target: sideStageDragHandle
587 sideStageDragHandle.dragging = false;
595 objectName: "spreadRepeater"
596 model: ApplicationManager
598 delegate: TransformedTabletSpreadDelegate {
600 objectName: model.appId ? "tabletSpreadDelegate_" + model.appId
601 : "tabletSpreadDelegate_null";
603 if (wantsMainStage) {
604 return spreadView.width;
606 return spreadView.sideStageWidth;
610 if (wantsMainStage) {
611 return spreadView.height;
613 return priv.landscapeHeight;
616 active: model.appId == priv.mainStageAppId || model.appId == priv.sideStageAppId
617 zIndex: spreadView.indexToZIndex(index)
618 selected: spreadView.selectedIndex == index
619 otherSelected: spreadView.selectedIndex >= 0 && !selected
620 isInSideStage: priv.sideStageAppId == model.appId
621 interactive: !spreadView.interactive && spreadView.phase === 0 && root.interactive
622 swipeToCloseEnabled: spreadView.interactive && !snapAnimation.running
623 maximizedAppTopMargin: root.maximizedAppTopMargin
624 dragOffset: !isDash && model.appId == priv.mainStageAppId && root.inverseProgress > 0 && spreadView.phase === 0 ? root.inverseProgress : 0
625 application: ApplicationManager.get(index)
628 readonly property bool wantsMainStage: model.stage == ApplicationInfoInterface.MainStage
630 readonly property bool isDash: model.appId == "unity8-dash"
633 target: spreadTile.application
634 property: "requestedState"
635 value: (spreadTile.isDash && root.keepDashRunning)
637 (!root.suspended && (model.appId == priv.mainStageAppId
638 || model.appId == priv.sideStageAppId))
639 ? ApplicationInfoInterface.RequestedRunning
640 : ApplicationInfoInterface.RequestedSuspended
643 // FIXME: A regular binding doesn't update any more after closing an app.
644 // Using a Binding for now.
648 value: (!spreadView.active && isDash && !active) ? -1 : spreadTile.zIndex
652 property real behavioredZIndex: zIndex
653 Behavior on behavioredZIndex {
654 enabled: spreadView.closingIndex >= 0
655 UbuntuNumberAnimation {}
658 // This is required because none of the bindings are triggered in some cases:
659 // When an app is closed, it might happen that ApplicationManager.get(nextInStack)
660 // returns a different app even though the nextInStackIndex and all the related
661 // bindings (index, mainStageApp, sideStageApp, etc) don't change. Let's force a
662 // binding update in that case.
664 target: ApplicationManager
665 onApplicationRemoved: spreadTile.z = Qt.binding(function() {
666 return spreadView.indexToZIndex(index);
671 var tileProgress = (spreadView.shiftedContentX - behavioredZIndex * spreadView.tileDistance) / spreadView.width;
672 // Some tiles (nextInStack, active) need to move directly from the beginning, normalize progress to immediately start at 0
673 if ((index == spreadView.nextInStack && spreadView.phase < 2) || (active && spreadView.phase < 1)) {
674 tileProgress += behavioredZIndex * spreadView.tileDistance / spreadView.width;
680 if (spreadView.phase == 0 && (spreadTile.active || spreadView.nextInStack == index)) {
681 if (progress < spreadView.positionMarker1) {
683 } else if (progress < spreadView.positionMarker1 + snappingCurve.period) {
684 return spreadView.positionMarker1 + snappingCurve.value * 3;
686 return spreadView.positionMarker2;
692 shellOrientationAngle: wantsMainStage ? root.shellOrientationAngle : 0
693 shellOrientation: wantsMainStage ? root.shellOrientation : Qt.PortraitOrientation
694 shellPrimaryOrientation: wantsMainStage ? root.shellPrimaryOrientation : Qt.PortraitOrientation
695 nativeOrientation: wantsMainStage ? root.nativeOrientation : Qt.PortraitOrientation
699 if (spreadView.phase == 2) {
700 spreadView.snapTo(index);
706 spreadView.draggedDelegateCount++;
708 spreadView.draggedDelegateCount--;
713 spreadView.closingIndex = index;
714 ApplicationManager.stopApplication(ApplicationManager.get(index).appId);
719 when: model.appId == priv.mainStageAppId
720 property: "mainAppWindowOrientationAngle"
721 value: appWindowOrientationAngle
725 when: model.appId == priv.mainStageAppId
726 property: "mainAppOrientationChangesEnabled"
727 value: orientationChangesEnabled
732 type: EasingCurve.Linear
733 period: (spreadView.positionMarker2 - spreadView.positionMarker1) / 3
734 progress: spreadTile.progress - spreadView.positionMarker1
741 //eat touch events during the right edge gesture
744 enabled: spreadDragArea.dragging
747 DirectionalDragArea {
749 objectName: "spreadDragArea"
750 anchors { top: parent.top; right: parent.right; bottom: parent.bottom }
751 width: root.dragAreaWidth
752 direction: Direction.Leftwards
753 enabled: (spreadView.phase != 2 && root.spreadEnabled) || dragging
755 property var gesturePoints: new Array()
759 spreadView.phase = 0;
760 spreadView.contentX = -spreadView.shift;
764 var dragX = -touchX + spreadDragArea.width - spreadView.shift;
765 var maxDrag = spreadView.width * spreadView.positionMarker4 - spreadView.shift;
766 spreadView.contentX = Math.min(dragX, maxDrag);
768 gesturePoints.push(touchX);
773 // Gesture recognized. Start recording this gesture
776 // Ok. The user released. Find out if it was a one-way movement.
777 var oneWayFlick = priv.evaluateOneWayFlick(gesturePoints);
780 if (oneWayFlick && spreadView.shiftedContentX < spreadView.positionMarker1 * spreadView.width) {
781 // If it was a short one-way movement, do the Alt+Tab switch
782 // no matter if we didn't cross positionMarker1 yet.
783 spreadView.snapTo(spreadView.nextInStack);
785 if (spreadView.shiftedContentX < spreadView.width * spreadView.positionMarker1) {
787 } else if (spreadView.shiftedContentX < spreadView.width * spreadView.positionMarker2) {
788 spreadView.snapTo(spreadView.nextInStack);
790 // otherwise snap to the closest snap position we can find
791 // (might be back to start, to app 1 or to spread)