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