16

I am attempting a rich-text editor (with HTML export capability) for an iPhone application I am working on, and decided to use iOS 5's WebKit support for contentEditable/designMode. I have hit a wall with one issue which is breaking for what I need. When editing the content in the UIWebView, there is no automatic scrolling to follow the cursor, like, for example, in UITextView. When typing, the cursor continues under the scrollView and the user has to manually scroll up.

Here is some relevant code:

- (void)webViewDidFinishLoad:(UIWebView *)webView
{
    NSString *string = @"document.body.contentEditable=true;document.designMode='on';void(0)";
    [webView stringByEvaluatingJavaScriptFromString:string];

    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillShow:) name:UIKeyboardWillShowNotification object:nil];
}

Any ideas how to remedy this issue? I am not sure if this also occurs in Safari or only in UIWebView's WebKit implementation.

If you hit this problem, make sure to head over to https://bugreport.apple.com and duplicate rdar://16455638.

Léo Natan
  • 56,823
  • 9
  • 150
  • 195

5 Answers5

11

After hours of research I found a better solution, so I thought I'd share.

There is a bug in iOS 5's contentEditable implementation where the main scrollView will not scroll with the caret. If making the body tag (or any dynamically sized element) contentEditable, the caret will always go off screen.

If you set an editable div to overflow: scroll, you'll notice that the div will scroll. The div's scrolling doesn't "bounce" or have scroll bars by default, but you can apply the -webkit-overflow-scrolling: touch attribute to the div to fix this.

With this information, you can fix it with a wrapper like so:

<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width; initial-scale=1.0; maximum-scale=1.0; user-scalable=no;"/>
<style type="text/css">
    html, body {height: 100%;}
    body {
        margin: 0;
    }
    #wrap {
        height: 100%;
        width: 100%;
        overflow-y: scroll;
        -webkit-overflow-scrolling: touch;
    }
    #editor {
        padding: 10px;
    }
</style>
</head>
<body>
<div id="wrap">
    <div id="editor" contenteditable="true">

    </div>
</div>
</body>
</html>

Basically you are scrolling the div instead of the document.

Unfortuantly the div's "scrollView" isn't aware of the virtual keyboard, so the caret will disappear behind the keyboard. However, you'll notice that the caret position is still on screen at the bottom behind the keyboard. So to fix that, reduce the height of the div/UIWebView to accommodate the keyboard.

Something else you might want to do is disable scrolling on the main scrollView:

webView.scrollView.scrollEnabled = NO;

The main scrollView shouldn't scroll anyway, but it should prevent any scrolling glitches.

Luke
  • 13,678
  • 7
  • 45
  • 79
  • Hi Luke, I will set your answer as the accepted answer. I have actually attempted this myself (sans the `-webkit-overflow-scrolling`), but it was still somewhat buggy unfortunately. I am accepting because it is closest to what I needed in the question. – Léo Natan Jul 26 '12 at 23:49
  • I would add that you don't have to have a #wrap, you can switch all of that CSS onto the body, and that'll work as well. – Michael Heilemann Jun 21 '13 at 17:48
  • 1
    Great solution! But I noticed one small issue - If I select (highlight) a word and start scrolling the highlighted state remains while scrolling and goes only when scrolling ends. Anyway to overcome this? – vivek241 Nov 27 '13 at 06:47
  • And how do you resize the webview dynamically based on the key presses?? – Bourne Dec 19 '13 at 09:56
  • 3
    Is there a better solution for this when targeting iOS 7? – catlan Mar 27 '14 at 13:59
4

To answer my own question, we eventually ended up doing a lot of work to ensure this works correctly across multiple iOS versions. We found out that all the scroll events were due to the UIWebView trying to manage its UIWebScrollView. We decided instead of using a UIWebView, we would take the internal UIWebDocumentView/UIWebBrowserView and add it to our own scroll view. This allowed us to managed the content size and scrolling ourselves and removed most of the problems we previously experienced.

Léo Natan
  • 56,823
  • 9
  • 150
  • 195
  • I want to explore this route, but I'm concerned because those are private classes I think (not really documented). Did you have any issues when you submitted this app? Are there "safe" ways to do this and things to avoid? Like, do you have to create a UIWebView and then reparent the views, vs creating these other classes directly -- to avoid app store rejection? – eselk Feb 18 '15 at 22:36
  • 1
    @eselk We use many tricks, such as method swizzling, ISA swizzling, etc. In this case, it is much simpler. We take the subview from the scroll view, which is exposed in public API and add it to another scrollview, which is under our control. – Léo Natan Feb 19 '15 at 09:30
  • Sorry, to actually write code for this would take too much time. It's not a simple task. – Léo Natan Sep 12 '15 at 00:04
1

First You have to register keyboard notifications, and in that keyboardWillShow method, trigger a scroll method with 0.1 timer interval.

 -(void) keyboardWillShow:(NSNotification *)note
    {

        timer   =   [NSTimer    scheduledTimerWithTimeInterval:0.1 target:self selector:@selector(scroll) userInfo:nil repeats:YES];

    }

    -(void) keyboardWillHide:(NSNotification *)note
    {
        [timer invalidate];

    }

Make one member variable in the .h file and and allocate memory for it in init() method.

  NSString    *prevStr;

inside that scroll method, do the following.

-(void)scroll{

    if (![prevStr isEqualToString:[WebView stringByEvaluatingJavaScriptFromString:@"document.body.innerHTML"]]) {
        prevStr  =   [[WebView stringByEvaluatingJavaScriptFromString:@"document.body.innerHTML"] retain];
        NSInteger height = [[WebView stringByEvaluatingJavaScriptFromString:@"document.body.offsetHeight;"] intValue];
        NSString* javascript = [NSString stringWithFormat:@"window.scrollBy(0, %d);", height];   
        [WebView stringByEvaluatingJavaScriptFromString:javascript];   
    }

}

This will allow you to scroll to the bottom when you are editing the content and the content is larger than the WebView frame. And at other times you will be able to scroll to the top of the page(autoscroll will be put hold at that time).

Selvin
  • 12,333
  • 17
  • 59
  • 80
  • Hi selvin, wouldn't that scroll the webview to the bottom? This is not something I need at all times. For example, if I have a long text and I click in the middle of the text, the cursor should stay where I clicked, not scroll to the bottom. Or perhaps I misunderstood? – Léo Natan Dec 30 '11 at 16:25
  • Hi @Leo. Sadly I dont know how to do that. Because i am not able to get the current offset of the scrollview when you are placing cursor in mid of the text.Perhaps we should try to get the current content offset and if it is not close to the bottom, we have to stop the autoscroll. – Selvin Jan 02 '12 at 14:29
0

I couldn't do your trick of setting "overflow: scroll" since it messed up our already good css (if we changed the overflow when clicking the editable div, then the layout got messed up). Went with this:

$(this).on('keypress',function(e){
    //$(this) is the contenteditable div
    var lineHeight = parseInt($(this).css('line-height')) + 2;
    //gets the height of the div        
    newh=$(this).height();
    if (typeof oldh === 'undefined')
        oldh=newh;//saves the current height
    if(oldh!=newh)//checks if the height changes
    {
        //if height changes, scroll up by 1 line (plus a little 2 px)            
        window.scrollBy(0,lineHeight);
        oldh=newh;//resave the saved height since it changed
    }
});

Hope this helps someone. PITA

Jamie Katz
  • 145
  • 2
  • 9
0

You can try to get caret frame with little bit tricky hack and manually scroll to visible rect.

// Call this after every change in editable HTML document
func bodyDidChanged() {
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
        let caretRect = self.caretRect(inView: self.webView)
        self.webView.scrollView.scrollRectToVisible(caretRect.insetBy(dx: -2, dy: -2), animated: true)
    }
}

private func caretRect(inView parentView: UIView? ) -> CGRect {
    guard let parentView = parentView else {
        return CGRect.null
    }

    var rect: CGRect?
    for view in parentView.subviews {
        if view.isKind(of: NSClassFromString("UITextSelectionView")!) {
            // If have a selected text, then we seeking last blue dot (grabber) inside rangeView
            var rangeView = view.subviews.first?.subviews.last
            if rangeView == nil {
                // If text not selected then carret frame is same as rangeView frame
                rangeView = view.subviews.first
            }
            rect = rangeView?.frame
            break
        } else {
            rect = caretRect(inView: view)
            if let rect = rect, !rect.isNull {
                break
            }
        }
    }
    return rect ?? CGRect.null
}
fir
  • 387
  • 1
  • 3
  • 19