Unity 8
PhoneStage.qml
1 /*
2  * Copyright (C) 2014-2015 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.2
18 import Ubuntu.Components 0.1
19 import Ubuntu.Gestures 0.1
20 import Unity.Application 0.1
21 import Unity.Session 0.1
22 import Utils 0.1
23 import "../Components"
24 
25 Rectangle {
26  id: root
27 
28  // Controls to be set from outside
29  property int dragAreaWidth
30  property real maximizedAppTopMargin
31  property bool interactive
32  property bool spreadEnabled: true // If false, animations and right edge will be disabled
33  property real inverseProgress: 0 // This is the progress for left edge drags, in pixels.
34  property QtObject applicationManager: ApplicationManager
35  property bool focusFirstApp: true // If false, focused app will appear on right edge like other apps
36  property bool altTabEnabled: true
37  property real startScale: 1.1
38  property real endScale: 0.7
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  property bool beingResized: false
48  onBeingResizedChanged: {
49  if (beingResized) {
50  // Brace yourselves for impact!
51  spreadView.selectedIndex = -1;
52  spreadView.phase = 0;
53  spreadView.contentX = -spreadView.shift;
54  }
55  }
56  onSpreadEnabledChanged: {
57  if (!spreadEnabled) {
58  // reset. go back to display the focused app
59  spreadView.selectedIndex = -1;
60  spreadView.phase = 0;
61  spreadView.contentX = -spreadView.shift;
62  }
63  }
64  function updateFocusedAppOrientation() {
65  if (spreadRepeater.count > 0) {
66  spreadRepeater.itemAt(0).matchShellOrientation();
67  }
68 
69  for (var i = 1; i < spreadRepeater.count; ++i) {
70 
71  var spreadDelegate = spreadRepeater.itemAt(i);
72 
73  var delta = spreadDelegate.appWindowOrientationAngle - root.shellOrientationAngle;
74  if (delta < 0) { delta += 360; }
75  delta = delta % 360;
76 
77  var supportedOrientations = spreadDelegate.application.supportedOrientations;
78  if (supportedOrientations === Qt.PrimaryOrientation) {
79  supportedOrientations = spreadDelegate.shellPrimaryOrientation;
80  }
81 
82  if (delta === 180 && (supportedOrientations & spreadDelegate.shellOrientation)) {
83  spreadDelegate.matchShellOrientation();
84  }
85  }
86  }
87  function updateFocusedAppOrientationAnimated() {
88  if (spreadRepeater.count > 0) {
89  spreadRepeater.itemAt(0).animateToShellOrientation();
90  }
91  }
92 
93  // To be read from outside
94  readonly property var mainApp: applicationManager.focusedApplicationId
95  ? applicationManager.findApplication(applicationManager.focusedApplicationId)
96  : null
97  property int mainAppWindowOrientationAngle: 0
98  readonly property bool orientationChangesEnabled: priv.focusedAppOrientationChangesEnabled
99  && !priv.focusedAppDelegateIsDislocated
100  && !(priv.focusedAppDelegate && priv.focusedAppDelegate.xBehavior.running)
101  && spreadView.phase === 0
102 
103  // How far left the stage has been dragged
104  readonly property real dragProgress: spreadRepeater.count > 0 ? -spreadRepeater.itemAt(0).xTranslate : 0
105 
106  readonly property alias dragging: spreadDragArea.dragging
107 
108  // Only used by the tutorial right now, when it is teasing the right edge
109  property real dragAreaOverlap
110 
111  signal opened()
112 
113  color: "#111111"
114 
115  function select(appId) {
116  spreadView.snapTo(priv.indexOf(appId));
117  }
118 
119  onInverseProgressChanged: {
120  // This can't be a simple binding because that would be triggered after this handler
121  // while we need it active before doing the anition left/right
122  priv.animateX = (inverseProgress == 0)
123  if (inverseProgress == 0 && priv.oldInverseProgress > 0) {
124  // left edge drag released. Minimum distance is given by design.
125  if (priv.oldInverseProgress > units.gu(22)) {
126  applicationManager.requestFocusApplication("unity8-dash");
127  }
128  }
129  priv.oldInverseProgress = inverseProgress;
130  }
131 
132  // <FIXME-contentX> See rationale in the next comment with this tag
133  onWidthChanged: {
134  if (!root.beingResized) {
135  // we're being resized without a warning (ie, the corresponding property wasn't set
136  root.beingResized = true;
137  beingResizedTimer.start();
138  }
139  }
140  Timer {
141  id: beingResizedTimer
142  interval: 100
143  onTriggered: { root.beingResized = false; }
144  }
145 
146  Connections {
147  target: applicationManager
148 
149  onFocusRequested: {
150  if (spreadView.phase > 0) {
151  spreadView.snapTo(priv.indexOf(appId));
152  } else {
153  applicationManager.focusApplication(appId);
154  }
155  }
156 
157  onApplicationAdded: {
158  if (spreadView.phase == 2) {
159  spreadView.snapTo(applicationManager.count - 1);
160  } else {
161  spreadView.phase = 0;
162  spreadView.contentX = -spreadView.shift;
163  applicationManager.focusApplication(appId);
164  }
165  }
166 
167  onApplicationRemoved: {
168  // Unless we're closing the app ourselves in the spread,
169  // lets make sure the spread doesn't mess up by the changing app list.
170  if (spreadView.closingIndex == -1) {
171  spreadView.phase = 0;
172  spreadView.contentX = -spreadView.shift;
173  focusTopMostApp();
174  }
175  }
176 
177  function focusTopMostApp() {
178  if (applicationManager.count > 0) {
179  var topmostApp = applicationManager.get(0);
180  applicationManager.focusApplication(topmostApp.appId);
181  }
182  }
183  }
184 
185  QtObject {
186  id: priv
187 
188  property string focusedAppId: root.applicationManager.focusedApplicationId
189  property bool focusedAppOrientationChangesEnabled: false
190  readonly property int firstSpreadIndex: root.focusFirstApp ? 1 : 0
191  readonly property var focusedAppDelegate: {
192  var index = indexOf(focusedAppId);
193  return index >= 0 && index < spreadRepeater.count ? spreadRepeater.itemAt(index) : null
194  }
195 
196  property real oldInverseProgress: 0
197  property bool animateX: false
198 
199  onFocusedAppDelegateChanged: {
200  if (focusedAppDelegate) {
201  focusedAppDelegate.focus = true;
202  }
203  }
204 
205  property bool focusedAppDelegateIsDislocated: focusedAppDelegate && focusedAppDelegate.x !== 0
206 
207  function indexOf(appId) {
208  for (var i = 0; i < root.applicationManager.count; i++) {
209  if (root.applicationManager.get(i).appId == appId) {
210  return i;
211  }
212  }
213  return -1;
214  }
215 
216  // Is more stable than "spreadView.shiftedContentX === 0" as it filters out noise caused by
217  // Flickable.contentX changing due to resizes.
218  property bool fullyShowingFocusedApp: true
219  }
220  Timer {
221  id: fullyShowingFocusedAppUpdateTimer
222  interval: 100
223  onTriggered: {
224  priv.fullyShowingFocusedApp = spreadView.shiftedContentX === 0;
225  }
226  }
227 
228  Flickable {
229  id: spreadView
230  objectName: "spreadView"
231  anchors.fill: parent
232  interactive: (spreadDragArea.dragging || phase > 1) && draggedDelegateCount === 0
233  contentWidth: spreadRow.width - shift
234  contentX: -shift
235 
236  // This indicates when the spreadView is active. That means, all the animations
237  // are activated and tiles need to line up for the spread.
238  readonly property bool active: shiftedContentX > 0 || spreadDragArea.dragging || !root.focusFirstApp
239 
240  // The flickable needs to fill the screen in order to get touch events all over.
241  // However, we don't want to the user to be able to scroll back all the way. For
242  // that, the beginning of the gesture starts with a negative value for contentX
243  // so the flickable wants to pull it into the view already. "shift" tunes the
244  // distance where to "lock" the content.
245  readonly property real shift: width / 2
246  readonly property real shiftedContentX: contentX + shift
247 
248  property int tileDistance: width / 4
249 
250  // Those markers mark the various positions in the spread (ratio to screen width from right to left):
251  // 0 - 1: following finger, snap back to the beginning on release
252  property real positionMarker1: 0.2
253  // 1 - 2: curved snapping movement, snap to app 1 on release
254  property real positionMarker2: 0.3
255  // 2 - 3: movement follows finger, snaps back to app 1 on release
256  property real positionMarker3: 0.35
257  // passing 3, we detach movement from the finger and snap to 4
258  property real positionMarker4: 0.9
259 
260  // This is where the first app snaps to when bringing it in from the right edge.
261  property real snapPosition: 0.7
262 
263  // Phase of the animation:
264  // 0: Starting from right edge, a new app (index 1) comes in from the right
265  // 1: The app has reached the first snap position.
266  // 2: The list is dragged further and snaps into the spread view when entering phase 2
267  property int phase: 0
268 
269  property int selectedIndex: -1
270  property int draggedDelegateCount: 0
271  property int closingIndex: -1
272 
273  // <FIXME-contentX> Workaround Flickable's behavior of bringing contentX back between valid boundaries
274  // when resized. The proper way to fix this is refactoring PhoneStage so that it doesn't
275  // rely on having Flickable.contentX keeping an out-of-bounds value when it's set programatically
276  // (as opposed to having contentX reaching an out-of-bounds value through dragging, which will trigger
277  // the Flickable.boundsBehavior upon release).
278  onContentXChanged: { forceItToRemainStillIfBeingResized(); }
279  onShiftChanged: { forceItToRemainStillIfBeingResized(); }
280  function forceItToRemainStillIfBeingResized() {
281  if (root.beingResized && contentX != -spreadView.shift) {
282  contentX = -spreadView.shift;
283  }
284  }
285 
286  onShiftedContentXChanged: {
287  if (root.beingResized) {
288  // Flickabe.contentX wiggles during resizes. Don't react to it.
289  return;
290  }
291 
292  switch (phase) {
293  case 0:
294  // the "spreadEnabled" part is because when code does "phase = 0; contentX = -shift" to
295  // dismiss the spread because spreadEnabled went to false, for some reason, during tests,
296  // Flickable might jump in and change contentX value back, causing the code below to do
297  // "phase = 1" which will make the spread stay.
298  // It sucks that we have no control whatsoever over whether or when Flickable animates its
299  // contentX.
300  if (root.spreadEnabled && shiftedContentX > width * positionMarker2) {
301  phase = 1;
302  }
303  break;
304  case 1:
305  if (shiftedContentX < width * positionMarker2) {
306  phase = 0;
307  } else if (shiftedContentX >= width * positionMarker4 && !spreadDragArea.dragging) {
308  phase = 2;
309  }
310  break;
311  }
312  fullyShowingFocusedAppUpdateTimer.restart();
313  }
314 
315  function snap() {
316  if (shiftedContentX < positionMarker1 * width) {
317  snapAnimation.targetContentX = -shift;
318  snapAnimation.start();
319  } else if (shiftedContentX < positionMarker2 * width) {
320  snapTo(1);
321  } else if (shiftedContentX < positionMarker3 * width) {
322  snapTo(1);
323  } else if (phase < 2){
324  // Add 1 pixel to make sure we definitely hit positionMarker4 even with rounding errors of the animation.
325  snapAnimation.targetContentX = width * positionMarker4 + 1 - shift;
326  snapAnimation.start();
327  root.opened();
328  }
329  }
330  function snapTo(index) {
331  if (!root.altTabEnabled) {
332  // Reset to start instead
333  snapAnimation.targetContentX = -shift;
334  snapAnimation.start();
335  return;
336  }
337  if (root.applicationManager.count <= index) {
338  // In case we're trying to snap to some non existing app, lets snap back to the first one
339  index = 0;
340  }
341  spreadView.selectedIndex = index;
342  // If we're not in full spread mode yet, always unwind to start pos
343  // otherwise unwind up to progress 0 of the selected index
344  if (spreadView.phase < 2) {
345  snapAnimation.targetContentX = -shift;
346  } else {
347  snapAnimation.targetContentX = -shift + index * spreadView.tileDistance;
348  }
349  snapAnimation.start();
350  }
351 
352  // In case the applicationManager already holds an app when starting up we're missing animations
353  // Make sure we end up in the same state
354  Component.onCompleted: {
355  spreadView.contentX = -spreadView.shift
356  priv.animateX = true;
357  snapAnimation.complete();
358  }
359 
360  SequentialAnimation {
361  id: snapAnimation
362  property int targetContentX: -spreadView.shift
363 
364  UbuntuNumberAnimation {
365  target: spreadView
366  property: "contentX"
367  to: snapAnimation.targetContentX
368  duration: UbuntuAnimation.FastDuration
369  }
370 
371  ScriptAction {
372  script: {
373  if (spreadView.selectedIndex >= 0) {
374  root.applicationManager.focusApplication(root.applicationManager.get(spreadView.selectedIndex).appId);
375 
376  spreadView.selectedIndex = -1;
377  spreadView.phase = 0;
378  spreadView.contentX = -spreadView.shift;
379  }
380  }
381  }
382  }
383 
384  MouseArea {
385  id: spreadRow
386  // This width controls how much the spread can be flicked left/right. It's composed of:
387  // tileDistance * app count (with a minimum of 3 apps, in order to also allow moving 1 and 2 apps a bit)
388  // + some constant value (still scales with the screen width) which looks good and somewhat fills the screen
389  width: Math.max(3, root.applicationManager.count) * spreadView.tileDistance + (spreadView.width - spreadView.tileDistance) * 1.5
390  height: parent.height
391  Behavior on width {
392  enabled: spreadView.closingIndex >= 0
393  UbuntuNumberAnimation {}
394  }
395  onWidthChanged: {
396  if (spreadView.closingIndex >= 0) {
397  spreadView.contentX = Math.min(spreadView.contentX, width - spreadView.width - spreadView.shift);
398  }
399  }
400 
401  x: spreadView.contentX
402 
403  onClicked: {
404  if (root.altTabEnabled) {
405  spreadView.snapTo(0);
406  }
407  }
408 
409  Repeater {
410  id: spreadRepeater
411  objectName: "spreadRepeater"
412  model: root.applicationManager
413  delegate: TransformedSpreadDelegate {
414  id: appDelegate
415  objectName: "appDelegate" + index
416  startAngle: 45
417  endAngle: 5
418  startScale: root.startScale
419  endScale: root.endScale
420  startDistance: spreadView.tileDistance
421  endDistance: units.gu(.5)
422  width: spreadView.width
423  height: spreadView.height
424  selected: spreadView.selectedIndex == index
425  otherSelected: spreadView.selectedIndex >= 0 && !selected
426  interactive: !spreadView.interactive && spreadView.phase === 0
427  && priv.fullyShowingFocusedApp && root.interactive && isFocused
428  swipeToCloseEnabled: spreadView.interactive && root.interactive && !snapAnimation.running
429  maximizedAppTopMargin: root.maximizedAppTopMargin
430  dropShadow: spreadView.active || priv.focusedAppDelegateIsDislocated
431  focusFirstApp: root.focusFirstApp
432 
433  Binding {
434  target: appDelegate.application
435  property: "requestedState"
436  value: (isDash && root.keepDashRunning) || (!root.suspended && appDelegate.focus)
437  ? ApplicationInfoInterface.RequestedRunning
438  : ApplicationInfoInterface.RequestedSuspended
439  }
440 
441  readonly property bool isDash: model.appId == "unity8-dash"
442 
443  z: isDash && !spreadView.active ? -1 : behavioredIndex
444 
445  x: {
446  // focused app is always positioned at 0 except when following left edge drag
447  if (isFocused) {
448  if (!isDash && root.inverseProgress > 0 && spreadView.phase === 0) {
449  return root.inverseProgress;
450  }
451  return 0;
452  }
453  if (isDash && !spreadView.active && !spreadDragArea.dragging) {
454  return 0;
455  }
456 
457  // Otherwise line up for the spread
458  return spreadView.width + spreadIndex * spreadView.tileDistance;
459  }
460 
461  application: root.applicationManager.get(index)
462  closeable: !isDash
463 
464  property real behavioredIndex: index
465  Behavior on behavioredIndex {
466  enabled: spreadView.closingIndex >= 0
467  UbuntuNumberAnimation {
468  id: appXAnimation
469  onRunningChanged: {
470  if (!running) {
471  spreadView.closingIndex = -1;
472  }
473  }
474  }
475  }
476 
477  property var xBehavior: xBehavior
478  Behavior on x {
479  id: xBehavior
480  enabled: root.spreadEnabled &&
481  !spreadView.active &&
482  !snapAnimation.running &&
483  !spreadDragArea.pressed &&
484  priv.animateX &&
485  !root.beingResized
486  UbuntuNumberAnimation {
487  duration: UbuntuAnimation.BriskDuration
488  }
489  }
490 
491  // Each tile has a different progress value running from 0 to 1.
492  // 0: means the tile is at the right edge.
493  // 1: means the tile has finished the main animation towards the left edge.
494  // >1: after the main animation has finished, tiles will continue to move very slowly to the left
495  progress: {
496  var tileProgress = (spreadView.shiftedContentX - behavioredIndex * spreadView.tileDistance) / spreadView.width;
497  // Tile 1 needs to move directly from the beginning...
498  if (root.focusFirstApp && behavioredIndex == 1 && spreadView.phase < 2) {
499  tileProgress += spreadView.tileDistance / spreadView.width;
500  }
501  // Limiting progress to ~0 and 1.7 to avoid binding calculations when tiles are not
502  // visible.
503  // < 0 : The tile is outside the screen on the right
504  // > 1.7: The tile is *very* close to the left edge and covered by other tiles now.
505  // Using 0.0001 to differentiate when a tile should still be visible (==0)
506  // or we can hide it (< 0)
507  tileProgress = Math.max(-0.0001, Math.min(1.7, tileProgress));
508  return tileProgress;
509  }
510 
511  // This mostly is the same as progress, just adds the snapping to phase 1 for tiles 0 and 1
512  animatedProgress: {
513  if (spreadView.phase == 0 && index <= priv.firstSpreadIndex) {
514  if (progress < spreadView.positionMarker1) {
515  return progress;
516  } else if (progress < spreadView.positionMarker1 + 0.05){
517  // p : 0.05 = x : pm2
518  return spreadView.positionMarker1 + (progress - spreadView.positionMarker1) * (spreadView.positionMarker2 - spreadView.positionMarker1) / 0.05
519  } else {
520  return spreadView.positionMarker2;
521  }
522  }
523  return progress;
524  }
525 
526  // Hiding tiles when their progress is negative or reached the maximum
527  visible: (progress >= 0 && progress < 1.7)
528  || (isDash && priv.focusedAppDelegateIsDislocated)
529 
530 
531  shellOrientationAngle: root.shellOrientationAngle
532  shellOrientation: root.shellOrientation
533  shellPrimaryOrientation: root.shellPrimaryOrientation
534  nativeOrientation: root.nativeOrientation
535 
536  onClicked: {
537  if (root.altTabEnabled && spreadView.phase == 2) {
538  if (root.applicationManager.focusedApplicationId == root.applicationManager.get(index).appId) {
539  spreadView.snapTo(index);
540  } else {
541  root.applicationManager.requestFocusApplication(root.applicationManager.get(index).appId);
542  }
543  }
544  }
545 
546  onDraggedChanged: {
547  if (dragged) {
548  spreadView.draggedDelegateCount++;
549  } else {
550  spreadView.draggedDelegateCount--;
551  }
552  }
553 
554  onClosed: {
555  spreadView.closingIndex = index;
556  root.applicationManager.stopApplication(root.applicationManager.get(index).appId);
557  }
558 
559  Binding {
560  target: root
561  when: index == 0
562  property: "mainAppWindowOrientationAngle"
563  value: appWindowOrientationAngle
564  }
565  Binding {
566  target: priv
567  when: index == 0
568  property: "focusedAppOrientationChangesEnabled"
569  value: orientationChangesEnabled
570  }
571  }
572  }
573  }
574  }
575 
576  //eat touch events during the right edge gesture
577  MouseArea {
578  objectName: "eventEaterArea"
579  anchors.fill: parent
580  enabled: spreadDragArea.dragging
581  }
582 
583  DirectionalDragArea {
584  id: spreadDragArea
585  objectName: "spreadDragArea"
586  direction: Direction.Leftwards
587  enabled: (spreadView.phase != 2 && root.spreadEnabled) || dragging
588 
589  anchors { top: parent.top; right: parent.right; bottom: parent.bottom; rightMargin: -root.dragAreaOverlap }
590  width: root.dragAreaWidth
591 
592  property var gesturePoints: new Array()
593 
594  onTouchXChanged: {
595  if (dragging) {
596  // Gesture recognized. Let's move the spreadView with the finger
597  var dragX = Math.min(touchX + width, width); // Prevent dragging rightwards
598  dragX = -dragX + spreadDragArea.width - spreadView.shift;
599  // Don't allow dragging further than the animation crossing with phase2's animation
600  var maxMovement = spreadView.width * spreadView.positionMarker4 - spreadView.shift;
601 
602  spreadView.contentX = Math.min(dragX, maxMovement);
603  } else {
604  // Initial touch. Let's reset the spreadView to the starting position.
605  spreadView.phase = 0;
606  spreadView.contentX = -spreadView.shift;
607  }
608 
609  gesturePoints.push(touchX);
610  }
611 
612  onDraggingChanged: {
613  if (dragging) {
614  // A potential edge-drag gesture has started. Start recording it
615  gesturePoints = [];
616  } else {
617  // Ok. The user released. Find out if it was a one-way movement.
618  var oneWayFlick = true;
619  var smallestX = spreadDragArea.width;
620  for (var i = 0; i < gesturePoints.length; i++) {
621  if (gesturePoints[i] >= smallestX) {
622  oneWayFlick = false;
623  break;
624  }
625  smallestX = gesturePoints[i];
626  }
627  gesturePoints = [];
628 
629  if (oneWayFlick && spreadView.shiftedContentX > units.gu(2) &&
630  spreadView.shiftedContentX < spreadView.positionMarker1 * spreadView.width) {
631  // If it was a short one-way movement, do the Alt+Tab switch
632  // no matter if we didn't cross positionMarker1 yet.
633  spreadView.snapTo(1);
634  } else if (!dragging) {
635  // otherwise snap to the closest snap position we can find
636  // (might be back to start, to app 1 or to spread)
637  spreadView.snap();
638  }
639  }
640  }
641  }
642 }