Handling Android View clipping with NativeScript

Ever have a shadow that looks gorgeous on iOS but is cut off on Android? Yeah, let's fix that with NativeScript.

Nathan Walker
Posted on

The iOS and Android default view settings conundrum

On iOS, the default base class of UIView has a clipsToBounds property which defaults to false. Meaning by default, iOS does not clip subviews so layers and shadows from an inner view can be seen extending beyond it's initial bounds.

On Android however, view's clip their children by default (the opposite behavior to iOS). This is adjustable through the ViewGroup's setClipChildren and setClipToPadding API.

The Android default view clipping result

Take for example we have a gorgeously styled Button with NativeScript displaying perfectly on iOS but displays like this on the left with Android. We want it to look like the button on the right.

Android view clipping

What the heck?

Solution

One solution might be to submit a pull request to @nativescript/core defaulting Android ViewGroups to not clip by default so it matches iOS behavior. Maybe we should?

However, solutions to problems like these are actually surprisingly simple and quick with NativeScript. We just need a reusable utility method that takes a View reference and disables setClipChildren and setClipToPadding respectively for itself and it's parents inside a loaded event for any view that needs it.

ts
export function androidToggleClipping(event: EventData, enable = false) {
  if (__ANDROID__ && event?.object) {
    const nativeView = (event.object as View).nativeView
    if (!nativeView) {
      return
    }
    nativeView.setClipChildren?.(enable)
    nativeView.setClipToPadding?.(enable)

    const parent = nativeView.getParent()
    if (!parent) {
      return
    }
    parent.setClipChildren?.(enable)
    parent.setClipToPadding?.(enable)

    const grand = parent.getParent()
    if (!grand) {
      return
    }
    grand.setClipChildren?.(enable)
    grand.setClipToPadding?.(enable)
  }
}

We can now apply to any view that needs it through it's loaded event reference:

xml
<GridLayout class="awesome-style" (loaded)="loaded($event)"/>
ts
function loaded(event: EventData) {
  androidToggleClipping(event)
}

Our style now looks amazing on both iOS and Android.

Bonus: turn into an Angular directive

Various flavors offer advantages to make things like above even easier. For example, with Angular we can actually turn it into a Directive like this:

ts
import { Directive, ElementRef, inject } from '@angular/core'
import { androidToggleClipping } from './utils/view.util'

@Directive({
  selector: '[removeClipping]',
})
export class RemoveClippingDirective {
  ref = inject(ElementRef)

  constructor() {
    if (__ANDROID__) {
      this.ref.nativeElement.on('loaded', (event) => {
        androidToggleClipping(event)
      })
    }
  }
}

Now we can just simply declare any view to remove clipping by simply adding the attribute removeClipping:

xml
<GridLayout class="some-awesome-style" removeClipping />

Bonus: turn into a Vue directive

With Vue we can also turn it into a Directive like this:

ts
<script lang="ts" setup>
import { EventData } from '@nativescript/core';
import { NSVElement } from 'nativescript-vue';
import { androidToggleClipping } from '../utils/view.util';

const vRemoveClipping = {
  beforeMount: (el: NSVElement) => {
    if (__ANDROID__) {
      el.nativeView.on('loaded', (event: EventData) => {
        androidToggleClipping(event);
      });
    }
  },
};
function pressMe() {
  console.log('pressed me!');
}
</script>

<template>
  <Frame>
    <Page>
      <ActionBar title="Android View Clip Solution"> </ActionBar>

      <GridLayout rows="*,auto,*" class="p-4">
        <GridLayout row="1" @tap="pressMe">
          <!-- This styled container (primary-btn) will clip by default on Android,
            causing the styled shadow to be cut off by the outside grid container.
            Adding the v-remove-clipping directive disables clipping on Android,
            to make behavior more like ios non-clipping default behavior.
            You can try removing the "v-remove-clipping" directive to see difference.
          -->
          <GridLayout class="primary-btn" v-remove-clipping>
            <Label>Press Me</Label>
          </GridLayout>
        </GridLayout>
      </GridLayout>
    </Page>
  </Frame>
</template>

This too will also remove Android view clipping from any view that needs it by declaring the v-remove-clipping directive.

Further understanding

If we take for example a layout like this:

xml
<GridLayout rows="*,auto,*" class="p-4">
  <GridLayout row="1" (tap)="pressMe()">
    <GridLayout class="primary-btn">
      <Label>Press Me</Label>
    </GridLayout>
  </GridLayout>
</GridLayout>

We have some nested GridLayout's here which is not uncommon to see in any NativeScript app. The problem for Android here is that a GridLayout is technically a Java implemented org.nativescript.widgets.GridLayout as we can see here: https://github.com/NativeScript/NativeScript/blob/fd053df8ba1387e351d7252a9c53a85758a28491/packages/core/ui/layouts/grid-layout/index.android.ts#L49

And what is org.nativescript.widgets.GridLayout exactly? Well as we can see here: https://github.com/NativeScript/NativeScript/blob/fd053df8ba1387e351d7252a9c53a85758a28491/packages/ui-mobile-base/android/widgets/src/main/java/org/nativescript/widgets/GridLayout.java#L20

-> GridLayout extends LayoutBase whereby we'll find that: https://github.com/NativeScript/NativeScript/blob/fd053df8ba1387e351d7252a9c53a85758a28491/packages/ui-mobile-base/android/widgets/src/main/java/org/nativescript/widgets/LayoutBase.java#L14

-> LayoutBase extends ViewGroup. Ah ha! GridLayout is actually an Android ViewGroup!

That's why any layout container from @nativescript/core (meaning StackLayout, GridLayout, FlexboxLayout, etc.) are all Android ViewGroups which have clipping enabled by default from the Android platform API.

So the problem with this layout is that we have a GridLayout (an Android ViewGroup) surrounding the view which is styled with our shadow (the primary-btn class):

xml
<GridLayout rows="*,auto,*" class="p-4">
  <GridLayout row="1" (tap)="pressMe()"> // <-- clipping the styled view inside
    <GridLayout class="primary-btn"> // <-- clipped by default!
      <Label>Press Me</Label>
    </GridLayout>
  </GridLayout>
</GridLayout>

Adding a loaded event to our primary-btn view to remove the clipping with our utility above or simply appending a directive as mentioned in the bonus would simply disable it's default clipping behavior and allow things to look gorgeous.

So there we have it, mystery solved.

StackBlitz examples to see for yourself

You can see this behavior for yourself in these StackBlitz projects:


More from our Blog