Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Maintain WebView content scroll position on orientation change

Tags:

android

The browsers in Android 2.3+ do a good job at maintaining the scrolled position of content on an orientation changed.

I'm trying to achieve the same thing for a WebView which I display within an activity but without success.

I've tested the manifest change (android:configChanges="orientation|keyboardHidden") to avoid activity recreation on a rotation however because of the different aspect ratios the scroll position does not get set to where I want. Furthermore this is not a solution for me as I need to have different layouts in portrait and landscape.

I've tried saving the WebView state and restoring it but this resuls in the content being displayed at the top again.

Furthermore attempting to scroll in onPageFinished using scrollTo doesn't work even though the height of the WebView is non-zero at this point.

Any ideas? Thanks in advance. Peter.

Partial Solution:

My colleague managed to get scrolling working via a javascript solution. For simple scrolling to the same vertical position, the WebView's 'Y' scroll position is saved in onSaveInstanceState state. The following is then added to onPageFinished:

public void onPageFinished(final WebView view, final String url) {
    if (mScrollY > 0) {
        final StringBuilder sb = new StringBuilder("javascript:window.scrollTo(0, ");
        sb.append(mScrollY);
        sb.append("/ window.devicePixelRatio);");
        view.loadUrl(sb.toString());
    }
    super.onPageFinished(view, url);
}

You do get the slight flicker as the content jumps from the beginning to the new scroll position but it is barely noticeable. The next step is try a percentage based method (based on differences in height) and also investigate having the WebView save and restore its state.

like image 877
PJL Avatar asked Jul 28 '11 07:07

PJL


2 Answers

To restore the current position of a WebView during orientation change I'm afraid you will have to do it manually.

I used this method:

  1. Calculate actual percent of scroll in the WebView
  2. Save it in the onRetainNonConfigurationInstance
  3. Restore the position of the WebView when it's recreated

Because the width and height of the WebView is not the same in portrait and landscape mode, I use a percent to represent the user scroll position.

Step by step:

1) Calculate actual percent of scroll in the WebView

// Calculate the % of scroll progress in the actual web page content
private float calculateProgression(WebView content) {
    float positionTopView = content.getTop();
    float contentHeight = content.getContentHeight();
    float currentScrollPosition = content.getScrollY();
    float percentWebview = (currentScrollPosition - positionTopView) / contentHeight;
    return percentWebview;
}

2) Save it in the onRetainNonConfigurationInstance

Save the progress just before the orientation change

@Override
public Object onRetainNonConfigurationInstance() {
    OrientationChangeData objectToSave = new OrientationChangeData();
    objectToSave.mProgress = calculateProgression(mWebView);
    return objectToSave;
}

// Container class used to save data during the orientation change
private final static class OrientationChangeData {
    public float mProgress;
}

3) Restore the position of the WebView when it's recreated

Get the progress from the orientation change data

private boolean mHasToRestoreState = false;
private float mProgressToRestore;

@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    setContentView(R.layout.main);

    mWebView = (WebView) findViewById(R.id.WebView);
    mWebView.setWebViewClient(new MyWebViewClient());
    mWebView.loadUrl("http://stackoverflow.com/");

    OrientationChangeData data = (OrientationChangeData) getLastNonConfigurationInstance();
    if (data != null) {
        mHasToRestoreState = true;
        mProgressToRestore = data.mProgress;
    }
}

To restore the current position you will have to wait the page to be reloaded ( this method can be problematic if your page takes a long time to load)

private class MyWebViewClient extends WebViewClient {
    @Override
    public void onPageFinished(WebView view, String url) {
        if (mHasToRestoreState) {
            mHasToRestoreState = false;
            view.postDelayed(new Runnable() {
                @Override
                public void run() {
                    float webviewsize = mWebView.getContentHeight() - mWebView.getTop();
                    float positionInWV = webviewsize * mProgressToRestore;
                    int positionY = Math.round(mWebView.getTop() + positionInWV);
                    mWebView.scrollTo(0, positionY);
                }
            // Delay the scrollTo to make it work
            }, 300);
        }
        super.onPageFinished(view, url);
    }
}

During my test I encounter that you need to wait a little after the onPageFinished method is called to make the scroll working. 300ms should be ok. This delay make the display to flick (first display at scroll 0 then go to the correct position).

Maybe there is an other better way to do it but I'm not aware of.

like image 189
ol_v_er Avatar answered Oct 05 '22 18:10

ol_v_er


Why you should consider this answer over accepted answer:

Accepted answer provides decent and simple way to save scroll position, however it is far from perfect. The problem with that approach is that sometimes during rotation you won't even see any of the elements you saw on the screen before rotation. Element that was at the top of the screen can now be at the bottom after rotation. Saving position via percent of scroll is not very accurate and on large documents this inaccuracy can add up.

So here is another method: it's way more complicated, but it almost guarantees that you'll see exactly the same element after rotation that you saw before rotation. In my opinion, this leads to a much better user experience, especially on a large documents.

======

First of all, we will track current scroll position via javascript. This will allow us to know exactly which element is currently at the top of the screen and how much is it scrolled.

First, ensure that javascript is enabled for your WebView:

webView.getSettings().setJavaScriptEnabled(true);

Next, we need to create java class that will accept information from within javascript:

public class WebScrollListener {

    private String element;
    private int margin;

    @JavascriptInterface
    public void onScrollPositionChange(String topElementCssSelector, int topElementTopMargin) {
        Log.d("WebScrollListener", "Scroll position changed: " + topElementCssSelector + " " + topElementTopMargin);
        element = topElementCssSelector;
        margin = topElementTopMargin;
    }

}

Then we add this class to WebView:

scrollListener = new WebScrollListener(); // save this in an instance variable
webView.addJavascriptInterface(scrollListener, "WebScrollListener");

Now we need to insert javascript code into html page. This script will send scroll data to java (if you are generation html, just append this script; otherwise, you might need to resort to calling document.write() via webView.loadUrl("javascript:document.write(" + script + ")");):

<script>
    // We will find first visible element on the screen 
    // by probing document with the document.elementFromPoint function;
    // we need to make sure that we dont just return 
    // body element or any element that is very large;
    // best case scenario is if we get any element that 
    // doesn't contain other elements, but any small element is good enough;
    var findSmallElementOnScreen = function() {
        var SIZE_LIMIT = 1024;
        var elem = undefined;
        var offsetY = 0;
        while (!elem) {
            var e = document.elementFromPoint(100, offsetY);
            if (e.getBoundingClientRect().height < SIZE_LIMIT) {
                elem = e;
            } else {
                offsetY += 50;
            }
        }
        return elem;
    };

    // Convert dom element to css selector for later use
    var getCssSelector = function(el) {
        if (!(el instanceof Element)) 
            return;
        var path = [];
        while (el.nodeType === Node.ELEMENT_NODE) {
            var selector = el.nodeName.toLowerCase();
            if (el.id) {
                selector += '#' + el.id;
                path.unshift(selector);
                break;
            } else {
                var sib = el, nth = 1;
                while (sib = sib.previousElementSibling) {
                    if (sib.nodeName.toLowerCase() == selector)
                       nth++;
                }
                if (nth != 1)
                    selector += ':nth-of-type('+nth+')';
            }
            path.unshift(selector);
            el = el.parentNode;
        }
        return path.join(' > ');    
    };

    // Send topmost element and its top offset to java
    var reportScrollPosition = function() {
        var elem = findSmallElementOnScreen();
        if (elem) {
            var selector = getCssSelector(elem);
            var offset = elem.getBoundingClientRect().top;
            WebScrollListener.onScrollPositionChange(selector, offset);
        }
    }

    // We will report scroll position every time when scroll position changes,
    // but timer will ensure that this doesn't happen more often than needed
    // (scroll event fires way too rapidly)
    var previousTimeout = undefined;
    window.addEventListener('scroll', function() {
        clearTimeout(previousTimeout);
        previousTimeout = setTimeout(reportScrollPosition, 200);
    });
</script>

If you run your app at this point, you should already see messages in logcat telling you that the new scroll position is received.

Now we need to save webView state:

@Override
public void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);
    webView.saveState(outState);
    outState.putString("scrollElement", scrollListener.element);
    outState.putInt("scrollMargin", scrollListener.margin);
}

Then we read it in the onCreate (for Activity) or onCreateView (for fragment) method:

if (savedInstanceState != null) {
    webView.restoreState(savedInstanceState);
    initialScrollElement = savedInstanceState.getString("scrollElement");
    initialScrollMargin = savedInstanceState.getInt("scrollMargin");
}

We also need to add WebViewClient to our webView and override onPageFinished method:

@Override
public void onPageFinished(final WebView view, String url) {
    if (initialScrollElement != null) {
        // It's very hard to detect when web page actually finished loading;
        // At the time onPageFinished is called, page might still not be parsed
        // Any javascript inside <script>...</script> tags might still not be executed;
        // Dom tree might still be incomplete;
        // So we are gonna use a combination of delays and checks to ensure
        // that scroll position is only restored after page has actually finished loading
        webView.postDelayed(new Runnable() {
            @Override
            public void run() {
                String javascript = "(function ( selectorToRestore, positionToRestore ) {\n" +
                        "       var previousTop = 0;\n" +
                        "       var check = function() {\n" +
                        "           var elem = document.querySelector(selectorToRestore);\n" +
                        "           if (!elem) {\n" +
                        "               setTimeout(check, 100);\n" +
                        "               return;\n" +
                        "           }\n" +
                        "           var currentTop = elem.getBoundingClientRect().top;\n" +
                        "           if (currentTop !== previousTop) {\n" +
                        "               previousTop = currentTop;\n" +
                        "               setTimeout(check, 100);\n" +
                        "           } else {\n" +
                        "               window.scrollBy(0, currentTop - positionToRestore);\n" +
                        "           }\n" +
                        "       };\n" +
                        "       check();\n" +
                        "}('" + initialScrollElement + "', " + initialScrollMargin + "));";

                webView.loadUrl("javascript:" + javascript);
                initialScrollElement = null;
            }
        }, 300);
    }
}

This is it. After screen rotation, element that was at the top of your screen should now remain there.

like image 28
Alexey Avatar answered Oct 05 '22 16:10

Alexey