Unity 8
PhoneStage.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.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 int orientation: Qt.PortraitOrientation
35  property QtObject applicationManager: ApplicationManager
36  property bool focusFirstApp: true // If false, focused app will appear on right edge like other apps
37  property bool altTabEnabled: true
38  property real startScale: 1.1
39  property real endScale: 0.7
40 
41  // How far left the stage has been dragged
42  readonly property real dragProgress: spreadRepeater.count > 0 ? -spreadRepeater.itemAt(0).xTranslate : 0
43 
44  readonly property alias dragging: spreadDragArea.dragging
45 
46  // Only used by the tutorial right now, when it is teasing the right edge
47  property real dragAreaOverlap
48 
49  signal opened()
50 
51  color: "#111111"
52 
53  function select(appId) {
54  spreadView.snapTo(priv.indexOf(appId));
55  }
56 
57  onWidthChanged: {
58  spreadView.selectedIndex = -1;
59  spreadView.phase = 0;
60  spreadView.contentX = -spreadView.shift;
61  }
62 
63  onInverseProgressChanged: {
64  // This can't be a simple binding because that would be triggered after this handler
65  // while we need it active before doing the anition left/right
66  priv.animateX = (inverseProgress == 0)
67  if (inverseProgress == 0 && priv.oldInverseProgress > 0) {
68  // left edge drag released. Minimum distance is given by design.
69  if (priv.oldInverseProgress > units.gu(22)) {
70  applicationManager.requestFocusApplication("unity8-dash");
71  }
72  }
73  priv.oldInverseProgress = inverseProgress;
74  }
75 
76  Connections {
77  target: applicationManager
78 
79  onFocusRequested: {
80  if (spreadView.phase > 0) {
81  spreadView.snapTo(priv.indexOf(appId));
82  } else {
83  applicationManager.focusApplication(appId);
84  }
85  }
86 
87  onApplicationAdded: {
88  if (spreadView.phase == 2) {
89  spreadView.snapTo(applicationManager.count - 1);
90  } else {
91  spreadView.phase = 0;
92  spreadView.contentX = -spreadView.shift;
93  applicationManager.focusApplication(appId);
94  }
95  }
96 
97  onApplicationRemoved: {
98  // Unless we're closing the app ourselves in the spread,
99  // lets make sure the spread doesn't mess up by the changing app list.
100  if (spreadView.closingIndex == -1) {
101  spreadView.phase = 0;
102  spreadView.contentX = -spreadView.shift;
103  focusTopMostApp();
104  }
105  }
106 
107  function focusTopMostApp() {
108  if (applicationManager.count > 0) {
109  var topmostApp = applicationManager.get(0);
110  applicationManager.focusApplication(topmostApp.appId);
111  }
112  }
113  }
114 
115  QtObject {
116  id: priv
117 
118  readonly property int firstSpreadIndex: root.focusFirstApp ? 1 : 0
119  property string focusedAppId: applicationManager.focusedApplicationId
120  property var focusedApplication: applicationManager.findApplication(focusedAppId)
121  property var focusedAppDelegate: null
122 
123  property real oldInverseProgress: 0
124  property bool animateX: false
125 
126  onFocusedAppIdChanged: focusedAppDelegate = spreadRepeater.itemAt(0);
127 
128  onFocusedAppDelegateChanged: {
129  if (focusedAppDelegate) {
130  focusedAppDelegate.focus = true;
131  }
132  }
133 
134  function indexOf(appId) {
135  for (var i = 0; i < applicationManager.count; i++) {
136  if (applicationManager.get(i).appId == appId) {
137  return i;
138  }
139  }
140  return -1;
141  }
142 
143  }
144 
145  Flickable {
146  id: spreadView
147  objectName: "spreadView"
148  anchors.fill: parent
149  interactive: (spreadDragArea.status == DirectionalDragArea.Recognized || phase > 1)
150  && draggedDelegateCount === 0
151  contentWidth: spreadRow.width - shift
152  contentX: -shift
153 
154  // This indicates when the spreadView is active. That means, all the animations
155  // are activated and tiles need to line up for the spread.
156  readonly property bool active: shiftedContentX > 0 || spreadDragArea.status === DirectionalDragArea.Recognized || !root.focusFirstApp
157 
158  // The flickable needs to fill the screen in order to get touch events all over.
159  // However, we don't want to the user to be able to scroll back all the way. For
160  // that, the beginning of the gesture starts with a negative value for contentX
161  // so the flickable wants to pull it into the view already. "shift" tunes the
162  // distance where to "lock" the content.
163  readonly property real shift: width / 2
164  readonly property real shiftedContentX: contentX + shift
165 
166  property int tileDistance: width / 4
167 
168  // Those markers mark the various positions in the spread (ratio to screen width from right to left):
169  // 0 - 1: following finger, snap back to the beginning on release
170  property real positionMarker1: 0.2
171  // 1 - 2: curved snapping movement, snap to app 1 on release
172  property real positionMarker2: 0.3
173  // 2 - 3: movement follows finger, snaps back to app 1 on release
174  property real positionMarker3: 0.35
175  // passing 3, we detach movement from the finger and snap to 4
176  property real positionMarker4: 0.9
177 
178  // This is where the first app snaps to when bringing it in from the right edge.
179  property real snapPosition: 0.7
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: 0
186 
187  property int selectedIndex: -1
188  property int draggedDelegateCount: 0
189  property int closingIndex: -1
190 
191  property bool focusChanging: false
192 
193  onShiftedContentXChanged: {
194  switch (phase) {
195  case 0:
196  if (shiftedContentX > width * positionMarker2) {
197  phase = 1;
198  }
199  break;
200  case 1:
201  if (shiftedContentX < width * positionMarker2) {
202  phase = 0;
203  } else if (shiftedContentX >= width * positionMarker4 && !spreadDragArea.dragging) {
204  phase = 2;
205  }
206  break;
207  }
208  }
209 
210  function snap() {
211  if (shiftedContentX < positionMarker1 * width) {
212  snapAnimation.targetContentX = -shift;
213  snapAnimation.start();
214  } else if (shiftedContentX < positionMarker2 * width) {
215  snapTo(1);
216  } else if (shiftedContentX < positionMarker3 * width) {
217  snapTo(1);
218  } else if (phase < 2){
219  // Add 1 pixel to make sure we definitely hit positionMarker4 even with rounding errors of the animation.
220  snapAnimation.targetContentX = width * positionMarker4 + 1 - shift;
221  snapAnimation.start();
222  root.opened();
223  }
224  }
225  function snapTo(index) {
226  if (!root.altTabEnabled) {
227  // Reset to start instead
228  snapAnimation.targetContentX = -shift;
229  snapAnimation.start();
230  return;
231  }
232  if (applicationManager.count <= index) {
233  // In case we're trying to snap to some non existing app, lets snap back to the first one
234  index = 0;
235  }
236  spreadView.selectedIndex = index;
237  // If we're not in full spread mode yet, always unwind to start pos
238  // otherwise unwind up to progress 0 of the selected index
239  if (spreadView.phase < 2) {
240  snapAnimation.targetContentX = -shift;
241  } else {
242  snapAnimation.targetContentX = -shift + index * spreadView.tileDistance;
243  }
244  snapAnimation.start();
245  }
246 
247  // In case the applicationManager already holds an app when starting up we're missing animations
248  // Make sure we end up in the same state
249  Component.onCompleted: {
250  spreadView.contentX = -spreadView.shift
251  priv.animateX = true;
252  snapAnimation.complete();
253  }
254 
255  SequentialAnimation {
256  id: snapAnimation
257  property int targetContentX: -spreadView.shift
258 
259  UbuntuNumberAnimation {
260  target: spreadView
261  property: "contentX"
262  to: snapAnimation.targetContentX
263  duration: UbuntuAnimation.FastDuration
264  }
265 
266  ScriptAction {
267  script: {
268  if (spreadView.selectedIndex >= 0) {
269  applicationManager.focusApplication(applicationManager.get(spreadView.selectedIndex).appId);
270 
271  spreadView.selectedIndex = -1;
272  spreadView.phase = 0;
273  spreadView.contentX = -spreadView.shift;
274  }
275  }
276  }
277  }
278 
279  MouseArea {
280  id: spreadRow
281  // This width controls how much the spread can be flicked left/right. It's composed of:
282  // tileDistance * app count (with a minimum of 3 apps, in order to also allow moving 1 and 2 apps a bit)
283  // + some constant value (still scales with the screen width) which looks good and somewhat fills the screen
284  width: Math.max(3, applicationManager.count) * spreadView.tileDistance + (spreadView.width - spreadView.tileDistance) * 1.5
285  height: parent.height
286  Behavior on width {
287  enabled: spreadView.closingIndex >= 0
288  UbuntuNumberAnimation {}
289  }
290  onWidthChanged: {
291  if (spreadView.closingIndex >= 0) {
292  spreadView.contentX = Math.min(spreadView.contentX, width - spreadView.width - spreadView.shift);
293  }
294  }
295 
296  x: spreadView.contentX
297 
298  onClicked: {
299  if (root.altTabEnabled) {
300  spreadView.snapTo(0);
301  }
302  }
303 
304  Repeater {
305  id: spreadRepeater
306  objectName: "spreadRepeater"
307  model: applicationManager
308  delegate: TransformedSpreadDelegate {
309  id: appDelegate
310  objectName: "appDelegate" + index
311  startAngle: 45
312  endAngle: 5
313  startScale: root.startScale
314  endScale: root.endScale
315  startDistance: spreadView.tileDistance
316  endDistance: units.gu(.5)
317  width: spreadView.width
318  height: spreadView.height
319  selected: spreadView.selectedIndex == index
320  otherSelected: spreadView.selectedIndex >= 0 && !selected
321  interactive: !spreadView.interactive && spreadView.phase === 0
322  && spreadView.shiftedContentX === 0 && root.interactive && isFocused
323  swipeToCloseEnabled: spreadView.interactive && root.interactive && !snapAnimation.running
324  maximizedAppTopMargin: root.maximizedAppTopMargin
325  dropShadow: spreadView.active ||
326  (priv.focusedAppDelegate && priv.focusedAppDelegate.x !== 0)
327  focusFirstApp: root.focusFirstApp
328 
329  readonly property bool isDash: model.appId == "unity8-dash"
330 
331  z: isDash && !spreadView.active ? -1 : behavioredIndex
332 
333  x: {
334  // focused app is always positioned at 0 except when following left edge drag
335  if (isFocused) {
336  if (!isDash && root.inverseProgress > 0 && spreadView.phase === 0) {
337  return root.inverseProgress;
338  }
339  return 0;
340  }
341  if (isDash && !spreadView.active && !spreadDragArea.dragging) {
342  return 0;
343  }
344 
345  // Otherwise line up for the spread
346  return spreadView.width + spreadIndex * spreadView.tileDistance;
347  }
348 
349  application: applicationManager.get(index)
350  closeable: !isDash
351 
352  property real behavioredIndex: index
353  Behavior on behavioredIndex {
354  enabled: spreadView.closingIndex >= 0
355  UbuntuNumberAnimation {
356  id: appXAnimation
357  onRunningChanged: {
358  if (!running) {
359  spreadView.closingIndex = -1;
360  }
361  }
362  }
363  }
364 
365  Behavior on x {
366  enabled: root.spreadEnabled &&
367  !spreadView.active &&
368  !snapAnimation.running &&
369  priv.animateX
370  UbuntuNumberAnimation {
371  duration: UbuntuAnimation.BriskDuration
372  onRunningChanged: {
373  if (!running && root.inverseProgress == 0) {
374  spreadView.focusChanging = false;
375  }
376  }
377  }
378  }
379 
380  // Each tile has a different progress value running from 0 to 1.
381  // 0: means the tile is at the right edge.
382  // 1: means the tile has finished the main animation towards the left edge.
383  // >1: after the main animation has finished, tiles will continue to move very slowly to the left
384  progress: {
385  var tileProgress = (spreadView.shiftedContentX - behavioredIndex * spreadView.tileDistance) / spreadView.width;
386  // Tile 1 needs to move directly from the beginning...
387  if (root.focusFirstApp && behavioredIndex == 1 && spreadView.phase < 2) {
388  tileProgress += spreadView.tileDistance / spreadView.width;
389  }
390  // Limiting progress to ~0 and 1.7 to avoid binding calculations when tiles are not
391  // visible.
392  // < 0 : The tile is outside the screen on the right
393  // > 1.7: The tile is *very* close to the left edge and covered by other tiles now.
394  // Using 0.0001 to differentiate when a tile should still be visible (==0)
395  // or we can hide it (< 0)
396  tileProgress = Math.max(-0.0001, Math.min(1.7, tileProgress));
397  return tileProgress;
398  }
399 
400  // This mostly is the same as progress, just adds the snapping to phase 1 for tiles 0 and 1
401  animatedProgress: {
402  if (spreadView.phase == 0 && index <= priv.firstSpreadIndex) {
403  if (progress < spreadView.positionMarker1) {
404  return progress;
405  } else if (progress < spreadView.positionMarker1 + 0.05){
406  // p : 0.05 = x : pm2
407  return spreadView.positionMarker1 + (progress - spreadView.positionMarker1) * (spreadView.positionMarker2 - spreadView.positionMarker1) / 0.05
408  } else {
409  return spreadView.positionMarker2;
410  }
411  }
412  return progress;
413  }
414 
415  // Hiding tiles when their progress is negative or reached the maximum
416  visible: (progress >= 0 && progress < 1.7) ||
417  (isDash && priv.focusedAppDelegate.x !== 0)
418 
419  Binding {
420  target: appDelegate
421  property: "orientation"
422  when: appDelegate.interactive
423  value: root.orientation
424  }
425 
426  onClicked: {
427  if (root.altTabEnabled && spreadView.phase == 2) {
428  if (applicationManager.focusedApplicationId == applicationManager.get(index).appId) {
429  spreadView.snapTo(index);
430  } else {
431  applicationManager.requestFocusApplication(applicationManager.get(index).appId);
432  }
433  }
434  }
435 
436  onDraggedChanged: {
437  if (dragged) {
438  spreadView.draggedDelegateCount++;
439  } else {
440  spreadView.draggedDelegateCount--;
441  }
442  }
443 
444  onClosed: {
445  spreadView.closingIndex = index;
446  applicationManager.stopApplication(applicationManager.get(index).appId);
447  }
448  }
449  }
450  }
451  }
452 
453  EdgeDragArea {
454  id: spreadDragArea
455  objectName: "spreadDragArea"
456  direction: Direction.Leftwards
457  enabled: (spreadView.phase != 2 && root.spreadEnabled) || dragging
458 
459  anchors { top: parent.top; right: parent.right; bottom: parent.bottom; rightMargin: -root.dragAreaOverlap }
460  width: root.dragAreaWidth
461 
462  property var gesturePoints: new Array()
463 
464  onTouchXChanged: {
465  if (!dragging) {
466  // Initial touch. Let's reset the spreadView to the starting position.
467  spreadView.phase = 0;
468  spreadView.contentX = -spreadView.shift;
469  }
470  if (dragging && status == DirectionalDragArea.Recognized) {
471  // Gesture recognized. Let's move the spreadView with the finger
472  var dragX = Math.min(touchX + width, width); // Prevent dragging rightwards
473  dragX = -dragX + spreadDragArea.width - spreadView.shift;
474  // Don't allow dragging further than the animation crossing with phase2's animation
475  var maxMovement = spreadView.width * spreadView.positionMarker4 - spreadView.shift;
476  spreadView.contentX = Math.min(dragX, maxMovement);
477  }
478  gesturePoints.push(touchX);
479  }
480 
481  property int previousStatus: -1
482  property int currentStatus: DirectionalDragArea.WaitingForTouch
483 
484  onStatusChanged: {
485  previousStatus = currentStatus;
486  currentStatus = status;
487  }
488 
489  onDraggingChanged: {
490  if (dragging) {
491  // A potential edge-drag gesture has started. Start recording it
492  gesturePoints = [];
493  return;
494  }
495 
496  // Ok. The user released. Find out if it was a one-way movement.
497  var oneWayFlick = true;
498  var smallestX = spreadDragArea.width;
499  for (var i = 0; i < gesturePoints.length; i++) {
500  if (gesturePoints[i] >= smallestX) {
501  oneWayFlick = false;
502  break;
503  }
504  smallestX = gesturePoints[i];
505  }
506  gesturePoints = [];
507 
508  if (previousStatus == DirectionalDragArea.Recognized &&
509  oneWayFlick && spreadView.shiftedContentX > units.gu(2) &&
510  spreadView.shiftedContentX < spreadView.positionMarker1 * spreadView.width) {
511  // If it was a short one-way movement, do the Alt+Tab switch
512  // no matter if we didn't cross positionMarker1 yet.
513  spreadView.snapTo(1);
514  } else if (!dragging) {
515  // otherwise snap to the closest snap position we can find
516  // (might be back to start, to app 1 or to spread)
517  spreadView.snap();
518  }
519  }
520  }
521 }