Skip to content

Commit a293a23

Browse files
tlancinamhartington
authored andcommitted
fix(scroll): keyboard support for native scroll views
Closes #3727. Closes ##3956
1 parent d3c3e8c commit a293a23

File tree

3 files changed

+150
-35
lines changed

3 files changed

+150
-35
lines changed

Diff for: js/utils/keyboard.js

+17-23
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ function keyboardFocusIn(e) {
310310
if (!e.target ||
311311
e.target.readOnly ||
312312
!ionic.tap.isKeyboardElement(e.target) ||
313-
!(scrollView = inputScrollView(e.target))) {
313+
!(scrollView = ionic.DomUtil.getParentWithClass(e.target, SCROLL_CONTAINER_CSS))) {
314314
keyboardActiveElement = null;
315315
return;
316316
}
@@ -319,12 +319,23 @@ function keyboardFocusIn(e) {
319319

320320
// if using JS scrolling, undo the effects of native overflow scroll so the
321321
// scroll view is positioned correctly
322-
document.body.scrollTop = 0;
323-
scrollView.scrollTop = 0;
324-
ionic.requestAnimationFrame(function(){
322+
if (!scrollView.classList.contains("overflow-scroll")) {
325323
document.body.scrollTop = 0;
326324
scrollView.scrollTop = 0;
327-
});
325+
ionic.requestAnimationFrame(function(){
326+
document.body.scrollTop = 0;
327+
scrollView.scrollTop = 0;
328+
});
329+
330+
// any showing part of the document that isn't within the scroll the user
331+
// could touchmove and cause some ugly changes to the app, so disable
332+
// any touchmove events while the keyboard is open using e.preventDefault()
333+
if (window.navigator.msPointerEnabled) {
334+
document.addEventListener("MSPointerMove", keyboardPreventDefault, false);
335+
} else {
336+
document.addEventListener('touchmove', keyboardPreventDefault, false);
337+
}
338+
}
328339

329340
if (!ionic.keyboard.isOpen || ionic.keyboard.isClosing) {
330341
ionic.keyboard.isOpening = true;
@@ -336,14 +347,7 @@ function keyboardFocusIn(e) {
336347
// keyboard
337348
document.addEventListener('keydown', keyboardOnKeyDown, false);
338349

339-
// any showing part of the document that isn't within the scroll the user
340-
// could touchmove and cause some ugly changes to the app, so disable
341-
// any touchmove events while the keyboard is open using e.preventDefault()
342-
if (window.navigator.msPointerEnabled) {
343-
document.addEventListener("MSPointerMove", keyboardPreventDefault, false);
344-
} else {
345-
document.addEventListener('touchmove', keyboardPreventDefault, false);
346-
}
350+
347351

348352
// if we aren't using the plugin and the keyboard isn't open yet, wait for the
349353
// window to resize so we can get an accurate estimate of the keyboard size,
@@ -725,16 +729,6 @@ function getViewportHeight() {
725729
return windowHeight;
726730
}
727731

728-
function inputScrollView(ele) {
729-
while(ele) {
730-
if (ele.classList.contains(SCROLL_CONTAINER_CSS)) {
731-
return ele;
732-
}
733-
ele = ele.parentElement;
734-
}
735-
return null;
736-
}
737-
738732
function keyboardHasPlugin() {
739733
return !!(window.cordova && cordova.plugins && cordova.plugins.Keyboard);
740734
}

Diff for: js/views/scrollViewNative.js

+128-10
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@
205205
// scroll animation loop w/ easing
206206
// credit https://gist.github.com/dezinezync/5487119
207207
var start = Date.now(),
208-
duration = 1000, //milliseconds
208+
duration = 250, //milliseconds
209209
fromY = self.el.scrollTop,
210210
fromX = self.el.scrollLeft;
211211

@@ -239,6 +239,7 @@
239239

240240
} else {
241241
// done
242+
ionic.tap.removeClonedInputs(self.__container, self);
242243
self.resize();
243244
}
244245
}
@@ -293,28 +294,144 @@
293294

294295
// Event Handler
295296
var container = self.__container;
297+
// save height when scroll view is shrunk so we don't need to reflow
298+
var scrollViewOffsetHeight;
296299

297-
// should be unnecessary in native scrolling, but keep in case bugs show up
298-
self.scrollChildIntoView = NOOP;
300+
/**
301+
* Shrink the scroll view when the keyboard is up if necessary and if the
302+
* focused input is below the bottom of the shrunk scroll view, scroll it
303+
* into view.
304+
*/
305+
self.scrollChildIntoView = function(e) {
306+
//console.log("scrollChildIntoView at: " + Date.now());
307+
308+
// D
309+
var scrollBottomOffsetToTop = container.getBoundingClientRect().bottom;
310+
// D - A
311+
scrollViewOffsetHeight = container.offsetHeight;
312+
var alreadyShrunk = self.isShrunkForKeyboard;
313+
314+
var isModal = container.parentNode.classList.contains('modal');
315+
// 680px is when the media query for 60% modal width kicks in
316+
var isInsetModal = isModal && window.innerWidth >= 680;
317+
318+
/*
319+
* _______
320+
* |---A---| <- top of scroll view
321+
* | |
322+
* |---B---| <- keyboard
323+
* | C | <- input
324+
* |---D---| <- initial bottom of scroll view
325+
* |___E___| <- bottom of viewport
326+
*
327+
* All commented calculations relative to the top of the viewport (ie E
328+
* is the viewport height, not 0)
329+
*/
330+
if (!alreadyShrunk) {
331+
// shrink scrollview so we can actually scroll if the input is hidden
332+
// if it isn't shrink so we can scroll to inputs under the keyboard
333+
// inset modals won't shrink on Android on their own when the keyboard appears
334+
if ( ionic.Platform.isIOS() || ionic.Platform.isFullScreen || isInsetModal ) {
335+
// if there are things below the scroll view account for them and
336+
// subtract them from the keyboard height when resizing
337+
// E - D E D
338+
var scrollBottomOffsetToBottom = e.detail.viewportHeight - scrollBottomOffsetToTop;
339+
340+
// 0 or D - B if D > B E - B E - D
341+
var keyboardOffset = Math.max(0, e.detail.keyboardHeight - scrollBottomOffsetToBottom);
342+
343+
ionic.requestAnimationFrame(function(){
344+
// D - A or B - A if D > B D - A max(0, D - B)
345+
scrollViewOffsetHeight = scrollViewOffsetHeight - keyboardOffset;
346+
container.style.height = scrollViewOffsetHeight + "px";
347+
348+
//update scroll view
349+
self.resize();
350+
});
351+
}
352+
353+
self.isShrunkForKeyboard = true;
354+
}
355+
356+
/*
357+
* _______
358+
* |---A---| <- top of scroll view
359+
* | * | <- where we want to scroll to
360+
* |--B-D--| <- keyboard, bottom of scroll view
361+
* | C | <- input
362+
* | |
363+
* |___E___| <- bottom of viewport
364+
*
365+
* All commented calculations relative to the top of the viewport (ie E
366+
* is the viewport height, not 0)
367+
*/
368+
// if the element is positioned under the keyboard scroll it into view
369+
if (e.detail.isElementUnderKeyboard) {
370+
371+
ionic.requestAnimationFrame(function(){
372+
// update D if we shrunk
373+
if (self.isShrunkForKeyboard && !alreadyShrunk) {
374+
scrollBottomOffsetToTop = container.getBoundingClientRect().bottom;
375+
}
376+
377+
// middle of the scrollview, this is where we want to scroll to
378+
// (D - A) / 2
379+
var scrollMidpointOffset = scrollViewOffsetHeight * 0.5;
380+
//console.log("container.offsetHeight: " + scrollViewOffsetHeight);
381+
382+
// middle of the input we want to scroll into view
383+
// C
384+
var inputMidpoint = ((e.detail.elementBottom + e.detail.elementTop) / 2);
385+
386+
// distance from middle of input to the bottom of the scroll view
387+
// C - D C D
388+
var inputMidpointOffsetToScrollBottom = inputMidpoint - scrollBottomOffsetToTop;
389+
390+
//C - D + (D - A)/2 C - D (D - A)/ 2
391+
var scrollTop = inputMidpointOffsetToScrollBottom + scrollMidpointOffset;
392+
393+
if ( scrollTop > 0) {
394+
if (ionic.Platform.isIOS()) {
395+
//just shrank scroll view, give it some breathing room before scrolling
396+
setTimeout(function(){
397+
ionic.tap.cloneFocusedInput(container, self);
398+
self.scrollBy(0, scrollTop, true);
399+
self.onScroll();
400+
}, 32);
401+
} else {
402+
self.scrollBy(0, scrollTop, true);
403+
self.onScroll();
404+
}
405+
}
406+
});
407+
}
408+
409+
// Only the first scrollView parent of the element that broadcasted this event
410+
// (the active element that needs to be shown) should receive this event
411+
e.stopPropagation();
412+
};
299413

300414
self.resetScrollView = function() {
301415
//return scrollview to original height once keyboard has hidden
302-
if (self.isScrolledIntoView) {
303-
self.isScrolledIntoView = false;
416+
if (self.isShrunkForKeyboard) {
417+
self.isShrunkForKeyboard = false;
304418
container.style.height = "";
305-
container.style.overflow = "";
306-
self.resize();
307-
ionic.scroll.isScrolling = false;
308419
}
420+
self.resize();
309421
};
310422

311-
container.addEventListener('resetScrollView', self.resetScrollView);
312423
container.addEventListener('scroll', self.onScroll);
313424

314425
//Broadcasted when keyboard is shown on some platforms.
315426
//See js/utils/keyboard.js
316427
container.addEventListener('scrollChildIntoView', self.scrollChildIntoView);
317-
container.addEventListener('resetScrollView', self.resetScrollView);
428+
429+
// Listen on document because container may not have had the last
430+
// keyboardActiveElement, for example after closing a modal with a focused
431+
// input and returning to a previously resized scroll view in an ion-content.
432+
// Since we can only resize scroll views that are currently visible, just resize
433+
// the current scroll view when the keyboard is closed.
434+
document.addEventListener('resetScrollView', self.resetScrollView);
318435
},
319436

320437
__cleanup: function() {
@@ -336,6 +453,7 @@
336453
delete self.options.el;
337454

338455
self.resize = self.scrollTo = self.onScroll = self.resetScrollView = NOOP;
456+
self.scrollChildIntoView = NOOP;
339457
container = null;
340458
}
341459
});

Diff for: test/unit/views/scrollViewNative.unit.js

+5-2
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@ describe('Scroll View', function() {
1414

1515
it('Should bind to event listeners', function() {
1616
spyOn(sc,'addEventListener');
17+
spyOn(document,'addEventListener');
1718
var sv = new ionic.views.ScrollNative({
1819
el: sc
1920
});
2021

22+
expect(document.addEventListener).toHaveBeenCalled();
23+
expect(document.addEventListener.mostRecentCall.args[0]).toBe('resetScrollView');
2124
expect(sc.addEventListener).toHaveBeenCalled();
22-
expect(sc.addEventListener.callCount).toBe(4);
23-
expect(sc.addEventListener.mostRecentCall.args[0]).toBe('resetScrollView');
25+
expect(sc.addEventListener.callCount).toBe(2);
26+
expect(sc.addEventListener.mostRecentCall.args[0]).toBe('scrollChildIntoView');
2427
});
2528

2629
it('Should remove event listeners on cleanup', function() {

0 commit comments

Comments
 (0)