How to retain scroll position of ng-repeat in Angu

2019-01-13 13:07发布

问题:

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

回答1:

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>


回答2:

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.

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.



回答3:

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



回答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>



回答5:

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).



回答6:

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



回答7:

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



回答8:

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...



回答9:

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>