Material: dialog: iOS fixed position bug causes dialog to appear off screen

Created on 19 May 2017  路  15Comments  路  Source: angular/material

Actual Behavior:

  • On actual iOs Devices when opening a dialog into a fixed container sometimes the stretchDialogContainerToViewport function's 'getBoundingClientRect()' returns the windows scroll location instead of 0. This causes the dialog to appear off screen and become un-closeable. Issue stems from a known iOS bug since iOS8 where fixed elements return an improper location while scrolling & the act of clicking inside the touch interface can sometimes cause a scroll. See this for an explination of the behavior: open radar
  • mdDialog opening to fixed position should always have a top position of 0 or the top position of the fixed container.

CodePen (or steps to reproduce the issue): *

AngularJS Versions: *

  • Angular: 1.6.4
  • Material: 1.1.4

Additional Information:

  • Browser Type: * Mobile Safari
  • Browser Version: * 8+
  • OS: * iOS 8+
  • Stack Traces:

Proposed Solution
Listen for scroll and debounce stretchDialogContainerToViewport() function (angular material.js line: 10649, dialog.js line: 1173) In my project wrapping the function in a simple 100ms timeout solves the problem 90% of the time

function stretchDialogContainerToViewport(container, options) {
      setTimeout(function(){ //short debounce for ios fixed position while scrolling issue
        var isFixed = $window.getComputedStyle($document[0].body).position == 'fixed';
        var backdrop = options.backdrop ? $window.getComputedStyle(options.backdrop[0]) : null;
        var height = backdrop ? Math.min($document[0].body.clientHeight, Math.ceil(Math.abs(parseInt(backdrop.height, 10)))) : 0;

        var previousStyles = {
          top: container.css('top'),
          height: container.css('height')
        };

        // If the body is fixed, determine the distance to the viewport in relative from the parent.
        var parentTop = Math.abs(options.parent[0].getBoundingClientRect().top);

        container.css({
          top: (isFixed ? parentTop : 0) + 'px',
          height: height ? height + 'px' : '100%'
        });

        return function () {
          // Reverts the modified styles back to the previous values.
          // This is needed for contentElements, which should have the same styles after close
          // as before.
          container.css(previousStyles);
        };
      }, 100);
    };
important feedback iOS can't reproduce bug mobile polish

Most helpful comment

This isn't exclusive to safari on ios. I can reproduce it in chrome on the desktop (mac) and in a cordova app on android. The fix linked above works on the desktop. About to test in devices.

.md-dialog-container {
  height: 100% !important;
  position: fixed !important;
  top: 0px !important;
}

All 15 comments

+1

+1

+1

Any cleaner solutions found for this issue yet?

+1

If someone from the community was interested in testing, debugging, and submitting a PR for this, then we would gladly review the submission and work to get it merged. That said, this looks like a bug with Safari on iOS and may be outside of the scope of what we can support.

Adding a 100ms timeout that only solves the problem 90% of the time does not seem to be a desirable solution.

There is a workaround posted in https://github.com/angular/material/issues/10930#issuecomment-340497596 that may be useful here.

This isn't exclusive to safari on ios. I can reproduce it in chrome on the desktop (mac) and in a cordova app on android. The fix linked above works on the desktop. About to test in devices.

.md-dialog-container {
  height: 100% !important;
  position: fixed !important;
  top: 0px !important;
}

This may help you.

.config($provide => {
  'ngInject';
  // return a decorated delegate of the $mdDialog service
    $provide.decorator('$mdDialog', ($delegate, $timeout, $rootElement, $document, $window) => {
      // if a mobile IOS platform
        if ((/iPhone|iPad|iPod/i).test(navigator.userAgent)) {
          const delegate = new mdDialogDelegate($delegate, $timeout, $rootElement, $document, $window);
          // decorate delegate
            return delegate.decorate();
        }
        return $delegate;
    })
});

class mdDialogDelegate {
  constructor($delegate, $timeout, $rootElement, $document, $window) {
    this._$delegate = $delegate;
    this._$timeout = $timeout;
    this._$rootElement = $rootElement;
    this._$document = $document;
    this._$window = $window;
  }

  decorate() {
    // $mdDialog.show is our only point of entry that gets called when either a preset (custom or built-in) or a
      // custom object is passed as the options into the $mdDialog service.  So this is the function we need to modify.
      // Keep the original function for now.
      const cachedShowFunction = this._$delegate.show;
      // use the $mdDialog delegate to write a new show function to the $mdDialog service
      this._$delegate.show = opts => {
        // onShowing is an available callback that gets fired just before the dialog is positioned.  We need to add
          // our custom positioning logic to it in order to fix the IOS positioning bug.  In case someone else
          // implements logic in this callback somewhere else, we need to keep it.
          const cachedOnShowingFunction = opts.onShowing;
          // Custom positioning logic added to the onShowing callback
          const onShowing = (scope, element, modifiedOptions) => {
            // the parent can be passed in as a function, string, elmeent, or jqlite object
              // it needs to be assigned as a jqlite object
              let parent = modifiedOptions.parent;
              if (angular.isFunction(parent)) {
                parent(scope, element, modifiedOptions);
              } else if (angular.isString(parent)) {
                parent = angular.element(this._$document[0].querySelector(parent));
              } else {
                parent = angular.element(parent);
              }

              // If parent querySelector/getter function fails, or it's just null, find a default.
              // logic derived from angular js material library
              if (!(parent || {}).length) {
                let defaultParent;
                if (this._$rootElement[0] && this._$rootElement[0].querySelector) {
                  defaultParent = this._$rootElement[0].querySelector(':not(svg) > body');
                }
                if (!defaultParent) {
                  defaultParent = this._$rootElement[0];
                }
                if (defaultParent.nodeName === '#comment') {
                  defaultParent = this._$document[0].body;
                }
                parent = angular.element(defaultParent);
              }
              // need to capture the parent top, body height, and parent height before the position dialog logic run
              // in the library
              const parentTop = angular.copy(Math.abs(parent[0].getBoundingClientRect().top));
              const bodyHeight = angular.copy(this._$document[0].body.clientHeight);
              const parentHeight = angular.copy(Math.ceil(Math.abs(parseInt(this._$window.getComputedStyle(parent[0]).height, 10))));
              // after the position dialog logic runs in the library run our custom position dialog logic
              this._$timeout(() => {
                // see if the body is fixed, should have been set to fixed in the library at this point
                  const isFixed = this._$window.getComputedStyle(this._$document[0].body).position === 'fixed';
                  // see if the backdrop has been prepended
                  const backdropElement = parent.find('md-backdrop');
                  // if there is a backdrop make the height of the dialog as the lesser of the body height and parent height
                  const height = backdropElement ? Math.min(bodyHeight, parentHeight) : 0;
                  // apply the top and height positioning of the dialog
                  element.css({
                      top: `${(isFixed ? parentTop : 0)}px`,
                      height: `${height ? `${height}px` : '100%'}`
                  });
              });
              // if there was additional logic added to the onShowing callback, run it
              if (angular.isFunction(cachedOnShowingFunction)) {
                cachedOnShowingFunction(scope, element, modifiedOptions);
              }
          };
          // if a preset was used, assign it to _options, else assign it directly to the options object
          if (opts.constructor.name === 'Preset') {
            opts._options.onShowing = onShowing;
          } else {
            opts.onShowing = onShowing;
          }
          // call the original show function
          cachedShowFunction(opts);
      };
      // return the modified delegated $mdDialog service
      return this._$delegate;
  }
}

@Splaktar any chance to get a proper fix on this? This was reported soooo long ago, but the bug still exists.
I have an old system to manage and sadly many users report problems.
I'll try the proposed workaround, but a proper fix would be better.

Sure, I will bump the priority and try to take another look.

@Splaktar thank you for that. I hope the fix will be available on 20th May :)

I am also experiencing this issue.

I left a comment on the Apple bug (https://openradar.appspot.com/radar?id=6668472289329152) which has been open and reproducible for 5 years now. I'm not sure how we escalate it.

This isn't exclusive to safari on ios. I can reproduce it in chrome on the desktop (mac).

I've updated the CodePen demo to AngularJS 1.8.0 and AngularJS Material 1.2.0.

I am not able to reproduce this on Chrome, Firefox, or Safari on macOS Catalina.

Happens intermittently as it requires a scroll. Happens 80-90% of the time in my app though.

I also tried to reproduce it in the iOS 14 Simulator with an iPhone 11, but I was unable to do so. Since it was mentioned that it happens 80-90% of the time, I tried about 30 different attempts along with scrolling up, down, left, and right but every time it opened fine. In the few cases (~5%) where it did not open when I clicked the button, I verified that no dialog was open and that iOS apparently didn't think that it was a real click and didn't send the full click event to the button or AngularJS Material decided not to open the dialog for some other reason. But no dialogs were opened off screen (verified in Safari DevTools on host macOS).

Can anyone provide an updated reproduction demo along with reproduction steps that demonstrate this issue on iOS 14?

Was this page helpful?
0 / 5 - 0 ratings