Unity 8
TabletStage.qml
1 /*
2  * Copyright (C) 2014 Canonical, Ltd.
3  *
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.
7  *
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.
12  *
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/>.
15  */
16 
17 import QtQuick 2.0
18 import Ubuntu.Components 0.1
19 import Ubuntu.Gestures 0.1
20 import Unity.Application 0.1
21 import Utils 0.1
22 import "../Components"
23 
24 Rectangle {
25  id: root
26  objectName: "stages"
27  anchors.fill: parent
28  color: "#111111"
29 
30  // Controls to be set from outside
31  property bool shown: false
32  property bool moving: false
33  property int dragAreaWidth
34  property real maximizedAppTopMargin
35  property bool interactive
36  property real inverseProgress: 0 // This is the progress for left edge drags, in pixels.
37  property int orientation: Qt.PortraitOrientation
38 
39  onInverseProgressChanged: {
40  // This can't be a simple binding because that would be triggered after this handler
41  // while we need it active before doing the anition left/right
42  spreadView.animateX = (inverseProgress == 0)
43  if (inverseProgress == 0 && priv.oldInverseProgress > 0) {
44  // left edge drag released. Minimum distance is given by design.
45  if (priv.oldInverseProgress > units.gu(22)) {
46  ApplicationManager.requestFocusApplication("unity8-dash");
47  }
48  }
49  priv.oldInverseProgress = inverseProgress;
50  }
51 
52  QtObject {
53  id: priv
54 
55  property string focusedAppId: ApplicationManager.focusedApplicationId
56  property string oldFocusedAppId: ""
57 
58  property string mainStageAppId
59  property string sideStageAppId
60 
61  // For convenience, keep properties of the first two apps in the model
62  property string appId0
63  property string appId1
64 
65  property int oldInverseProgress: 0
66 
67  onFocusedAppIdChanged: {
68  if (priv.focusedAppId.length > 0) {
69  var focusedApp = ApplicationManager.findApplication(focusedAppId);
70  if (focusedApp.stage == ApplicationInfoInterface.SideStage) {
71  priv.sideStageAppId = focusedAppId;
72  } else {
73  priv.mainStageAppId = focusedAppId;
74  }
75  }
76 
77  appId0 = ApplicationManager.count >= 1 ? ApplicationManager.get(0).appId : "";
78  appId1 = ApplicationManager.count > 1 ? ApplicationManager.get(1).appId : "";
79 
80  // Update the QML focus accordingly
81  updateSpreadDelegateFocus();
82  }
83 
84  function updateSpreadDelegateFocus() {
85  if (priv.focusedAppId) {
86  var focusedAppIndex = priv.indexOf(priv.focusedAppId);
87  if (focusedAppIndex !== -1) {
88  spreadRepeater.itemAt(focusedAppIndex).focus = true;
89  } else {
90  console.warn("TabletStage: Failed to find the SpreadDelegate for appID=" + priv.focusedAppId);
91  }
92  }
93  }
94 
95  function indexOf(appId) {
96  for (var i = 0; i < ApplicationManager.count; i++) {
97  if (ApplicationManager.get(i).appId == appId) {
98  return i;
99  }
100  }
101  return -1;
102  }
103 
104  function evaluateOneWayFlick(gesturePoints) {
105  // Need to have at least 3 points to recognize it as a flick
106  if (gesturePoints.length < 3) {
107  return false;
108  }
109  // Need to have a movement of at least 2 grid units to recognize it as a flick
110  if (Math.abs(gesturePoints[gesturePoints.length - 1] - gesturePoints[0]) < units.gu(2)) {
111  return false;
112  }
113 
114  var oneWayFlick = true;
115  var smallestX = gesturePoints[0];
116  var leftWards = gesturePoints[1] < gesturePoints[0];
117  for (var i = 1; i < gesturePoints.length; i++) {
118  if ((leftWards && gesturePoints[i] >= smallestX)
119  || (!leftWards && gesturePoints[i] <= smallestX)) {
120  oneWayFlick = false;
121  break;
122  }
123  smallestX = gesturePoints[i];
124  }
125  return oneWayFlick;
126  }
127  }
128 
129  Connections {
130  target: ApplicationManager
131  onFocusRequested: {
132  if (spreadView.interactive) {
133  spreadView.snapTo(priv.indexOf(appId));
134  } else {
135  ApplicationManager.focusApplication(appId);
136  }
137  }
138 
139  onApplicationAdded: {
140  if (spreadView.phase == 2) {
141  spreadView.snapTo(ApplicationManager.count - 1);
142  } else {
143  spreadView.phase = 0;
144  spreadView.contentX = -spreadView.shift;
145  ApplicationManager.focusApplication(appId);
146  }
147  }
148 
149  onApplicationRemoved: {
150  if (priv.mainStageAppId == appId) {
151  ApplicationManager.focusApplication("unity8-dash")
152  }
153  if (priv.sideStageAppId == appId) {
154  priv.sideStageAppId = "";
155  }
156 
157  if (ApplicationManager.count == 0) {
158  spreadView.phase = 0;
159  spreadView.contentX = -spreadView.shift;
160  } else if (spreadView.closingIndex == -1) {
161  // Unless we're closing the app ourselves in the spread,
162  // lets make sure the spread doesn't mess up by the changing app list.
163  spreadView.phase = 0;
164  spreadView.contentX = -spreadView.shift;
165  ApplicationManager.focusApplication(ApplicationManager.get(0).appId);
166  }
167  }
168  }
169 
170  Flickable {
171  id: spreadView
172  anchors.fill: parent
173  interactive: (spreadDragArea.status == DirectionalDragArea.Recognized || phase > 1)
174  && draggedDelegateCount === 0
175  contentWidth: spreadRow.width - shift
176  contentX: -shift
177 
178  property int tileDistance: units.gu(20)
179  property int sideStageWidth: units.gu(40)
180  property bool sideStageVisible: priv.sideStageAppId
181 
182  // This indicates when the spreadView is active. That means, all the animations
183  // are activated and tiles need to line up for the spread.
184  readonly property bool active: shiftedContentX > 0 || spreadDragArea.dragging
185 
186  // The flickable needs to fill the screen in order to get touch events all over.
187  // However, we don't want to the user to be able to scroll back all the way. For
188  // that, the beginning of the gesture starts with a negative value for contentX
189  // so the flickable wants to pull it into the view already. "shift" tunes the
190  // distance where to "lock" the content.
191  readonly property real shift: width / 2
192  readonly property real shiftedContentX: contentX + shift
193 
194  // Phase of the animation:
195  // 0: Starting from right edge, a new app (index 1) comes in from the right
196  // 1: The app has reached the first snap position.
197  // 2: The list is dragged further and snaps into the spread view when entering phase 2
198  property int phase
199 
200  readonly property int phase0Width: sideStageWidth
201  readonly property int phase1Width: sideStageWidth
202 
203  // Those markers mark the various positions in the spread (ratio to screen width from right to left):
204  // 0 - 1: following finger, snap back to the beginning on release
205  readonly property real positionMarker1: 0.2
206  // 1 - 2: curved snapping movement, snap to nextInStack on release
207  readonly property real positionMarker2: sideStageWidth / spreadView.width
208  // 2 - 3: movement follows finger, snaps to phase 2 (full spread) on release
209  readonly property real positionMarker3: 0.6
210  // passing 3, we detach movement from the finger and snap to phase 2 (full spread)
211  readonly property real positionMarker4: 0.8
212 
213  readonly property int startSnapPosition: phase0Width * 0.5
214  readonly property int endSnapPosition: phase0Width * 0.75
215  readonly property real snapPosition: 0.75
216 
217  property int selectedIndex: -1
218  property int draggedDelegateCount: 0
219  property int closingIndex: -1
220 
221  property bool animateX: true
222 
223  property bool sideStageDragging: sideStageDragHandle.dragging
224  property real sideStageDragProgress: sideStageDragHandle.progress
225 
226  onSideStageDragProgressChanged: {
227  if (sideStageDragProgress == 1) {
228  ApplicationManager.focusApplication(priv.mainStageAppId);
229  priv.sideStageAppId = "";
230  }
231  }
232 
233  // In case the ApplicationManager already holds an app when starting up we're missing animations
234  // Make sure we end up in the same state
235  Component.onCompleted: {
236  spreadView.contentX = -spreadView.shift
237  }
238 
239  property int nextInStack: {
240  switch (state) {
241  case "main":
242  if (ApplicationManager.count > 1) {
243  return 1;
244  }
245  return -1;
246  case "mainAndOverlay":
247  if (ApplicationManager.count <= 2) {
248  return -1;
249  }
250  if (priv.appId0 == priv.mainStageAppId || priv.appId0 == priv.sideStageAppId) {
251  if (priv.appId1 == priv.mainStageAppId || priv.appId1 == priv.sideStageAppId) {
252  return 2;
253  }
254  return 1;
255  }
256  return 0;
257  case "overlay":
258  return 1;
259  }
260  print("Unhandled nextInStack case! This shouldn't happen any more when the Dash is an app!");
261  return -1;
262  }
263  property int nextZInStack: indexToZIndex(nextInStack)
264 
265  states: [
266  State {
267  name: "empty"
268  },
269  State {
270  name: "main"
271  },
272  State { // Side Stage only in overlay mode
273  name: "overlay"
274  },
275  State { // Main Stage and Side Stage in overlay mode
276  name: "mainAndOverlay"
277  },
278  State { // Main Stage and Side Stage in split mode
279  name: "mainAndSplit"
280  }
281  ]
282  state: {
283  if (priv.mainStageAppId && !priv.sideStageAppId) {
284  return "main";
285  }
286  if (!priv.mainStageAppId && priv.sideStageAppId) {
287  return "overlay";
288  }
289  if (priv.mainStageAppId && priv.sideStageAppId) {
290  return "mainAndOverlay";
291  }
292  return "empty";
293  }
294 
295  onShiftedContentXChanged: {
296  if (spreadView.phase == 0 && spreadView.shiftedContentX > spreadView.width * spreadView.positionMarker2) {
297  spreadView.phase = 1;
298  } else if (spreadView.phase == 1 && spreadView.shiftedContentX > spreadView.width * spreadView.positionMarker4) {
299  spreadView.phase = 2;
300  } else if (spreadView.phase == 1 && spreadView.shiftedContentX < spreadView.width * spreadView.positionMarker2) {
301  spreadView.phase = 0;
302  }
303  }
304 
305  function snap() {
306  if (shiftedContentX < phase0Width) {
307  snapAnimation.targetContentX = -shift;
308  snapAnimation.start();
309  } else if (shiftedContentX < phase1Width) {
310  snapTo(1);
311  } else {
312  // Add 1 pixel to make sure we definitely hit positionMarker4 even with rounding errors of the animation.
313  snapAnimation.targetContentX = spreadView.width * spreadView.positionMarker4 + 1 - shift;
314  snapAnimation.start();
315  }
316  }
317  function snapTo(index) {
318  spreadView.selectedIndex = index;
319  snapAnimation.targetContentX = -shift;
320  snapAnimation.start();
321  }
322 
323  // We need to shuffle z ordering a bit in order to keep side stage apps above main stage apps.
324  // We don't want to really reorder them in the model because that allows us to keep track
325  // of the last focused order.
326  function indexToZIndex(index) {
327  var app = ApplicationManager.get(index);
328  if (!app) {
329  return index;
330  }
331 
332  var active = app.appId == priv.mainStageAppId || app.appId == priv.sideStageAppId;
333  if (active && app.stage == ApplicationInfoInterface.MainStage) {
334  // if this app is active, and its the MainStage, always put it to index 0
335  return 0;
336  }
337  if (active && app.stage == ApplicationInfoInterface.SideStage) {
338  if (!priv.mainStageAppId) {
339  // Only have SS apps running. Put the active one at 0
340  return 0;
341  }
342 
343  // Precondition now: There's an active MS app and this is SS app:
344  if (spreadView.nextInStack >= 0 && ApplicationManager.get(spreadView.nextInStack).stage == ApplicationInfoInterface.MainStage) {
345  // If the next app coming from the right is a MS app, we need to elevate this SS ap above it.
346  // Put it to at least level 2, or higher if there's more apps coming in before this one.
347  return Math.max(index, 2);
348  } else {
349  // 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.
350  return 1;
351  }
352  }
353  if (index <= 2 && app.stage == ApplicationInfoInterface.MainStage && priv.sideStageAppId) {
354  // Ok, this is an inactive MS app. If there's an active SS app around, we need to place this one
355  // in between the active MS app and the active SS app, so that it comes in from there when dragging from the right.
356  // If there's now active SS app, just leave it where it is.
357  return priv.indexOf(priv.sideStageAppId) < index ? index - 1 : index;
358  }
359  if (index == spreadView.nextInStack && app.stage == ApplicationInfoInterface.SideStage) {
360  // This is a SS app and the next one to come in from the right:
361  if (priv.sideStageAppId && priv.mainStageAppId) {
362  // If there's both, an active MS and an active SS app, put this one right on top of that
363  return 2;
364  }
365  // Or if there's only one other active app, put it on top of that.
366  // The case that there isn't any other active app is already handled above.
367  return 1;
368  }
369  if (index == 2 && spreadView.nextInStack == 1 && priv.sideStageAppId) {
370  // If its index 2 but not the next one to come in, it means
371  // we've pulled another one down to index 2. Move this one up to 2 instead.
372  return 3;
373  }
374  // don't touch all others... (mostly index > 3 + simple cases where the above doesn't shuffle much)
375  return index;
376  }
377 
378  SequentialAnimation {
379  id: snapAnimation
380  property int targetContentX: -spreadView.shift
381 
382  UbuntuNumberAnimation {
383  target: spreadView
384  property: "contentX"
385  to: snapAnimation.targetContentX
386  duration: UbuntuAnimation.FastDuration
387  }
388 
389  ScriptAction {
390  script: {
391  if (spreadView.selectedIndex >= 0) {
392  var newIndex = spreadView.selectedIndex;
393  spreadView.selectedIndex = -1;
394  ApplicationManager.focusApplication(ApplicationManager.get(newIndex).appId);
395  spreadView.phase = 0;
396  spreadView.contentX = -spreadView.shift;
397  }
398  }
399  }
400  }
401 
402  MouseArea {
403  id: spreadRow
404  x: spreadView.contentX
405  height: root.height
406  width: spreadView.width + Math.max(spreadView.width, ApplicationManager.count * spreadView.tileDistance)
407 
408  onClicked: {
409  spreadView.snapTo(0);
410  }
411 
412  Rectangle {
413  id: sideStageBackground
414  color: "black"
415  anchors.fill: parent
416  anchors.leftMargin: spreadView.width - (1 - sideStageDragHandle.progress) * spreadView.sideStageWidth
417  z: spreadView.indexToZIndex(priv.indexOf(priv.sideStageAppId))
418  opacity: spreadView.phase == 0 ? 1 : 0
419  Behavior on opacity { UbuntuNumberAnimation {} }
420  }
421 
422  Item {
423  id: sideStageDragHandle
424  anchors { top: parent.top; bottom: parent.bottom; left: parent.left; leftMargin: spreadView.width - spreadView.sideStageWidth - width }
425  width: units.gu(2)
426  z: sideStageBackground.z
427  opacity: spreadView.phase <= 0 && spreadView.sideStageVisible ? 1 : 0
428  property real progress: 0
429  property bool dragging: false
430 
431  Behavior on opacity { UbuntuNumberAnimation {} }
432 
433  Connections {
434  target: spreadView
435  onSideStageVisibleChanged: {
436  if (spreadView.sideStageVisible) {
437  sideStageDragHandle.progress = 0;
438  }
439  }
440  }
441 
442  Image {
443  anchors.centerIn: parent
444  anchors.horizontalCenterOffset: parent.progress * spreadView.sideStageWidth - (width - parent.width) / 2
445  width: sideStageDragHandleMouseArea.pressed ? parent.width * 2 : parent.width
446  height: parent.height
447  source: "graphics/sidestage_handle@20.png"
448  Behavior on width { UbuntuNumberAnimation {} }
449  }
450 
451  MouseArea {
452  id: sideStageDragHandleMouseArea
453  anchors.fill: parent
454  enabled: spreadView.shiftedContentX == 0
455  property int startX
456  property var gesturePoints: new Array()
457 
458  onPressed: {
459  gesturePoints = [];
460  startX = mouseX;
461  sideStageDragHandle.progress = 0;
462  sideStageDragHandle.dragging = true;
463  }
464  onMouseXChanged: {
465  if (priv.mainStageAppId) {
466  sideStageDragHandle.progress = Math.max(0, (-startX + mouseX) / spreadView.sideStageWidth);
467  }
468  gesturePoints.push(mouseX);
469  }
470  onReleased: {
471  if (priv.mainStageAppId) {
472  var oneWayFlick = priv.evaluateOneWayFlick(gesturePoints);
473  sideStageDragSnapAnimation.to = sideStageDragHandle.progress > 0.5 || oneWayFlick ? 1 : 0;
474  sideStageDragSnapAnimation.start();
475  } else {
476  sideStageDragHandle.dragging = false;
477  }
478  }
479  }
480  UbuntuNumberAnimation {
481  id: sideStageDragSnapAnimation
482  target: sideStageDragHandle
483  property: "progress"
484 
485  onRunningChanged: {
486  if (!running) {
487  sideStageDragHandle.dragging = false;
488  }
489  }
490  }
491  }
492 
493  Repeater {
494  id: spreadRepeater
495  model: ApplicationManager
496 
497  delegate: TransformedTabletSpreadDelegate {
498  id: spreadTile
499  height: spreadView.height
500  width: model.stage == ApplicationInfoInterface.MainStage ? spreadView.width : spreadView.sideStageWidth
501  active: model.appId == priv.mainStageAppId || model.appId == priv.sideStageAppId
502  zIndex: spreadView.indexToZIndex(index)
503  selected: spreadView.selectedIndex == index
504  otherSelected: spreadView.selectedIndex >= 0 && !selected
505  isInSideStage: priv.sideStageAppId == model.appId
506  interactive: !spreadView.interactive && spreadView.phase === 0 && root.interactive
507  swipeToCloseEnabled: spreadView.interactive && !snapAnimation.running
508  maximizedAppTopMargin: root.maximizedAppTopMargin
509  dragOffset: !isDash && model.appId == priv.mainStageAppId && root.inverseProgress > 0 && spreadView.phase === 0 ? root.inverseProgress : 0
510  application: ApplicationManager.get(index)
511  closeable: !isDash
512 
513  readonly property bool isDash: model.appId == "unity8-dash"
514 
515  // FIXME: A regular binding doesn't update any more after closing an app.
516  // Using a Binding for now.
517  Binding {
518  target: spreadTile
519  property: "z"
520  value: (!spreadView.active && isDash && !active) ? -1 : spreadTile.zIndex
521  }
522  x: spreadView.width
523 
524  property real behavioredZIndex: zIndex
525  Behavior on behavioredZIndex {
526  enabled: spreadView.closingIndex >= 0
527  UbuntuNumberAnimation {}
528  }
529 
530  // This is required because none of the bindings are triggered in some cases:
531  // When an app is closed, it might happen that ApplicationManager.get(nextInStack)
532  // returns a different app even though the nextInStackIndex and all the related
533  // bindings (index, mainStageApp, sideStageApp, etc) don't change. Let's force a
534  // binding update in that case.
535  Connections {
536  target: ApplicationManager
537  onApplicationRemoved: spreadTile.z = Qt.binding(function() {
538  return spreadView.indexToZIndex(index);
539  })
540  }
541 
542  progress: {
543  var tileProgress = (spreadView.shiftedContentX - behavioredZIndex * spreadView.tileDistance) / spreadView.width;
544  // Some tiles (nextInStack, active) need to move directly from the beginning, normalize progress to immediately start at 0
545  if ((index == spreadView.nextInStack && spreadView.phase < 2) || (active && spreadView.phase < 1)) {
546  tileProgress += behavioredZIndex * spreadView.tileDistance / spreadView.width;
547  }
548  return tileProgress;
549  }
550 
551  animatedProgress: {
552  if (spreadView.phase == 0 && (spreadTile.active || spreadView.nextInStack == index)) {
553  if (progress < spreadView.positionMarker1) {
554  return progress;
555  } else if (progress < spreadView.positionMarker1 + snappingCurve.period) {
556  return spreadView.positionMarker1 + snappingCurve.value * 3;
557  } else {
558  return spreadView.positionMarker2;
559  }
560  }
561  return progress;
562  }
563 
564  Binding {
565  target: spreadTile
566  property: "orientation"
567  when: spreadTile.interactive
568  value: root.orientation
569  }
570 
571  onClicked: {
572  if (spreadView.phase == 2) {
573  spreadView.snapTo(index);
574  }
575  }
576 
577  onDraggedChanged: {
578  if (dragged) {
579  spreadView.draggedDelegateCount++;
580  } else {
581  spreadView.draggedDelegateCount--;
582  }
583  }
584 
585  onClosed: {
586  spreadView.closingIndex = index;
587  ApplicationManager.stopApplication(ApplicationManager.get(index).appId);
588  }
589 
590  EasingCurve {
591  id: snappingCurve
592  type: EasingCurve.Linear
593  period: (spreadView.positionMarker2 - spreadView.positionMarker1) / 3
594  progress: spreadTile.progress - spreadView.positionMarker1
595  }
596  }
597  }
598  }
599  }
600 
601  EdgeDragArea {
602  id: spreadDragArea
603  anchors { top: parent.top; right: parent.right; bottom: parent.bottom }
604  width: root.dragAreaWidth
605  direction: Direction.Leftwards
606 
607  property var gesturePoints: new Array()
608 
609  onTouchXChanged: {
610  if (!dragging) {
611  spreadView.phase = 0;
612  spreadView.contentX = -spreadView.shift;
613  }
614 
615  if (dragging) {
616  var dragX = -touchX + spreadDragArea.width - spreadView.shift;
617  var maxDrag = spreadView.width * spreadView.positionMarker4 - spreadView.shift;
618  spreadView.contentX = Math.min(dragX, maxDrag);
619  }
620  gesturePoints.push(touchX);
621  }
622 
623  onDraggingChanged: {
624  if (dragging) {
625  // Gesture recognized. Start recording this gesture
626  gesturePoints = [];
627  return;
628  }
629 
630  // Ok. The user released. Find out if it was a one-way movement.
631  var oneWayFlick = priv.evaluateOneWayFlick(gesturePoints);
632  gesturePoints = [];
633 
634  if (oneWayFlick && spreadView.shiftedContentX < spreadView.positionMarker1 * spreadView.width) {
635  // If it was a short one-way movement, do the Alt+Tab switch
636  // no matter if we didn't cross positionMarker1 yet.
637  spreadView.snapTo(spreadView.nextInStack);
638  } else if (!dragging) {
639  if (spreadView.shiftedContentX < spreadView.width * spreadView.positionMarker1) {
640  spreadView.snap();
641  } else if (spreadView.shiftedContentX < spreadView.width * spreadView.positionMarker2) {
642  spreadView.snapTo(spreadView.nextInStack);
643  } else {
644  // otherwise snap to the closest snap position we can find
645  // (might be back to start, to app 1 or to spread)
646  spreadView.snap();
647  }
648  }
649  }
650  }
651 }