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