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