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