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