30

DEMO

List of objects is rendered using ng-repeat. Suppose that this list is very long and user is scrolling to the bottom to see some objects.

While user is observing an object, a new item is added to the top of the list. This causes the observed object to change its position, i.e. the user suddenly sees something else at the same place of the observed object.

How would you go about keeping the observed object at the same place when new items are added?

PLAYGROUND HERE

Misha Moroshko
  • 148,413
  • 200
  • 467
  • 700

9 Answers9

31

It maybe solved quite elegantly, by using scrollTop property of div. I used two directives - one handles scroll position of the wrapper element, the other register new elements. Give me a shout if anything unclear.

DEMO

JS:

.directive("keepScroll", function(){

  return {

    controller : function($scope){
      var element = null;

      this.setElement = function(el){
        element = el;
      }

      this.addItem = function(item){
        console.log("Adding item", item, item.clientHeight);
        element.scrollTop = (element.scrollTop+item.clientHeight+1);
       //1px for margin from your css (surely it would be possible
       // to make it more generic, rather then hard-coding the value)
      };

    },

    link : function(scope,el,attr, ctrl) {

     ctrl.setElement(el[0]);

    }

  };

})

.directive("scrollItem", function(){

  return{
    require : "^keepScroll",
    link : function(scope, el, att, scrCtrl){
      scrCtrl.addItem(el[0]);
    }
  }
})

HTML:

<div class="wrapper" keep-scroll>
  <div class="item" scroll-item ng-repeat="item in items  | orderBy: '-id'">
    {{ item.name }}
   </div>
</div>
artur grzesiak
  • 19,110
  • 5
  • 43
  • 54
  • 1
    artur, any quick comments on easy ways to update this example to retain the scroll position for both top and bottom additions in an "angular way"? – Matt Way Aug 10 '14 at 11:39
  • This was extremely helpful. Thanks, Artur! – chris Dec 27 '14 at 16:47
  • @MattWay - Here is my solution that does that: http://stackoverflow.com/a/27722587/126574 – VitalyB Dec 31 '14 at 14:41
  • How could this be modified to maintain the scroll position when removing the top item? – shadowcursor Nov 14 '15 at 03:30
  • I'd wrap scrCtrl.addItem(el[0]); in $timeout(), to ensure size rendering. – Kindzoku Jul 11 '16 at 09:14
  • Brilliant! Very helpful! Personally, I chose to implement the solution with just a directive, I used `querySelector` to get the parent, but your idea was a good start. Simple and effective! – Peter_Fretter Sep 19 '16 at 16:18
7

You know other people are trying to solve this problem using a different approach in terms of UI. They don't just POP new items on top, but instead they show a small clickable link on top stating how many new items are added since he last checked it.

[2 new items, Click here to refresh]

item 5
item 4
item 3

Check out how twitter is solving this. **[Let me attach a screenshot for you shortly.]**

I know it's a bit contradicting with want you want, but perhaps this is better in terms of UX? User wants to know if there are new items coming in.

fedmich
  • 5,243
  • 3
  • 35
  • 52
5

You could scroll by the amount of the height of the added elements

$scope.addNewItem = function() {
    var wrapper = document.getElementsByClassName('wrapper')[0];

    $scope.items = $scope.items.concat({
      id: $scope.items.length,
      name: "item " + $scope.items.length
    });
    // will fail if you observe the item 0 because we scroll before the view is updated;
    wrapper.scrollTop+=101; //height+margings+paddings
  };

I am using a bad practice of accessing the DOM from the controller. A more modular approach would be to create a directive which will handle all cases and change the scroll position after the view is updated.

Demo at http://jsbin.com/zofofapo/8/edit


Alternatively, for the case where the items are not equally high, you could see how much scroll is left before the insertion, and re-set it after the insertion

$scope.addNewItem = function() {
    var wrapper = document.getElementsByClassName('wrapper')[0],
        scrollRemaining = wrapper.scrollHeight - wrapper.scrollTop;

    $scope.items = $scope.items.concat({
      id: $scope.items.length,
      name: "item " + $scope.items.length
    });
    // will fail if you observe the item 0 because we scroll before the view is updated;
    $timeout(function(){
      wrapper.scrollTop = wrapper.scrollHeight - scrollRemaining;
    },0);
  };

Demo at http://jsbin.com/zofofapo/9/edit

Gabriele Petrioli
  • 173,972
  • 30
  • 239
  • 291
  • It is probably the most straightforward solution, but it has the disadvantage of this "jumping" behavior of the scrollbar and the view itself. – Misha Moroshko May 21 '14 at 23:36
  • I believe that you can overcome this issue, by disabling the scroll (*`overflow:hidden`*) before insertion and enabling it afterwards.. Will look into it later. – Gabriele Petrioli May 22 '14 at 07:07
4

Below is an improvement to Arthur's version that prevents scrolling regardless if the added item is added above or below the scroll: JS Bin

angular.module("Demo", [])

.controller("DemoCtrl", function($scope) {
  $scope.items = [];
  
  for (var i = 0; i < 10; i++) {
    $scope.items[i] = {
      id: i,
      name: 'item ' + i
    };
  }
  
  $scope.addNewItemTop = function() {
    $scope.items.unshift({
      id: $scope.items.length,
      name: "item " + $scope.items.length
    });
  };
  
  $scope.addNewItemMiddle = function() {
    $scope.items.splice(5, 0, {
      id: $scope.items.length,
      name: "item " + $scope.items.length
    });
  };
  
  $scope.addNewItemBottom = function() {
    $scope.items = $scope.items.concat({
      id: $scope.items.length,
      name: "item " + $scope.items.length
    });
  };
})

.directive("keepScroll", function(){
  
  return {

    controller : function($scope){
      var element = 0;
      
      this.setElement = function(el){
        element = el;
      };

      this.addItem = function(item){
        console.group("Item");
        console.log("OffsetTop: " + item.offsetTop);
        console.log("ScrollTop: " + element.scrollTop);
        
        if(item.offsetTop <= element.scrollTop) {
          console.log("Adjusting scorlltop position");
          element.scrollTop = (element.scrollTop+item.clientHeight+1); //1px for margin
        } else {
          console.log("Not adjusting scroll");
        }
        console.groupEnd("Item");
      };
      
    },
    
    link : function(scope,el,attr, ctrl) {
      
     ctrl.setElement(el[0]);
      
    }
    
  };
  
})

.directive("scrollItem", function(){
  
  
  return{
    require : "^keepScroll",
    link : function(scope, el, att, scrCtrl){
      scrCtrl.addItem(el[0]);
    }
  };
});
.wrapper {
  width: 200px;
  height: 300px;
  border: 1px solid black;
  overflow: auto;
  /* Required for correct offsetParent */
  position: relative; 
}
.item {
  background-color: #ccc;
  height: 100px;
  margin-bottom: 1px;
}
<!DOCTYPE html>
<html>
<head>
<script src="//code.angularjs.org/1.3.0-beta.7/angular.js"></script>
  <meta charset="utf-8">
  <title>JS Bin</title>
</head>
<body ng-app="Demo" ng-controller="DemoCtrl">
  <div class="wrapper" keep-scroll>
    <div class="item" scroll-item ng-repeat="item in items">
      {{ item.name }}
    </div>
  </div>
  <button ng-click="addNewItemTop()">
    Add New Item Top
  </button>
  <button ng-click="addNewItemMiddle()">
    Add New Item Middle
  </button>
  <button ng-click="addNewItemBottom()">
    Add New Item Bottom
  </button>
</body>
</html>
VitalyB
  • 10,803
  • 7
  • 64
  • 88
  • Any thoughts or resources on how to accomplish this with elements of varying height? Note, in this thread's case, scrollTop is adjusted properly since the element height is preset at 100px. For varying height elements, element data are not yet bound so the height is not known. – MoMo Jul 08 '16 at 13:05
3

You could defer adding the items until the user scrolls the top of the list into view. There is no point rendering the items before.

It could look like this (perhaps with an added animation).

lyschoening
  • 16,412
  • 10
  • 37
  • 50
  • 1
    Interesting approach! – Misha Moroshko May 21 '14 at 23:33
  • yeah really great approach, man. it's effectively the same as what's desired, and an animation would let the user know the "newness" of stuff. great work. thank you so very much for providing a working example with your suggestion, that is excellent coder comradeship. – sova Oct 05 '16 at 23:49
2

The FaceBook Way: Everyone is suggesting this, here is a pseudo-implementation:

MYEXAMPLE

As new objects are added, pop them into a "More Queue".

  <div style="height:15px">
      <button  ng-if="moreQueue"ng-click="transferWait()"> See More {{ moreQueue }}
      </button >
    </div>  
  <div class="wrapper">
    <div class="item" ng-repeat="item in items | orderBy: '-id'">
      {{ item.name }}
    </div>
  </div>

MessageHandlerController will have at least 2 arrays (we should treat as queue's b/c we'll pop from bottom, up.

  • Active Messages
  • Waiting Messages

As your Signal R/Service Bus populates your WaitingQueue, your ng-if renders increases in size and your $scope.SizeOfWaitingQueue=SizeOfWaitingQueue(). This re-assigning process should happen every iteration so you don't have to dirty check your More size Repo

Dave Alperovich
  • 31,680
  • 8
  • 71
  • 97
1

You need to add a scrollspy directive to your container that updates its position on every user scroll, and gets notified on every repeat render so it can reposition it self to its saved state. your html might look like this

<div scrollspy id="myscrollspy">
     <ul>
       <li ng-repeat="" notifyscroll></li>
     </ul>
</div>

the scroll spy would have the required css overflow settings and scroll-x or scroll-y to keep track of the current scroll and avoid polluting the scope it should also watch for an event comming from the ng-repeat that tells him a change occured and should set the scrool.

ng-repeat could notify by attaching a new directive notify scroll that launches an event. not sure if curretn version of angular supports postrender event.

the way to position the scroll will depend on whether you are using a 3rd party library $.scrollTop(pos) or no. this will do it or should. hope it helps

Dayan Moreno Leon
  • 4,839
  • 2
  • 19
  • 24
0

There are, I believe, only a few possible solutions

1) Don't add the item (as per the other answer)

2) Add the item at bottom, so the list doesn't move.

3) Add the item at top and scroll the screen automatically so that the new item's height is accounted for, and everything is kept seemingly as before. The list will move down, but the viewable screen itself will also move - so relatively nothing will be seen to move. Well other elements that are not part of list will, but that might actually look quite nice...

Community
  • 1
  • 1
NimChimpsky
  • 43,542
  • 55
  • 186
  • 295
0

You can solve this problem with ng-animate:

.animation('.keep-scroll', [function () {
    var keepScroll = function(element, leave){
        var elementPos = element.offset().top;
        var scrollPos = document.body.scrollTop;

        if(elementPos < scrollPos){
            var height = element[0].clientHeight;
            if(leave){
                height *= (-1);
            }
            document.body.scrollTop += height;
        }
    };

    return {
        enter: function (element, doneFn) {
            keepScroll(element);
            doneFn();
        },
        leave: function (element, doneFn) {
            keepScroll(element, true);
            doneFn();
        }
    };
}])

Just assign the css-class .keep-scroll to your repeated elements, like this:

<div ng-repeat="item in items" class="keep-scroll">...</div>
Martin Cremer
  • 3,729
  • 2
  • 23
  • 31