Resizing Images in Nativescript

NOTE: There is an updated post with more details on working with images in Nativescript, check that out first:

Working with Images in Nativescript
This post explains how to extend a picture gallery app to allow adding images from the device, and how to use Nativescript to resize, save and edit images in your own application.

It's been a few months since I started using Nativescript Vue so I thought I'd share some more tips for new Nativescript developers. I discussed some of my initial development experiments in the last two blog posts using Firebase and Nativescript Vue to create a basic social media app skeleton. Starting from that base, I created Nerdaly, a social media application written in Nativescript Vue with a NodeJS backend. Large images led to delays in uploading new posts and slow rendering, so I decided to limit the size of images sent by the client. In this post, I'll discuss controlling image file sizes in Nativescript by reducing pixel dimensions to produce smaller (and faster) image file uploads.

At first, I first tried a few higher-level approaches, such as using another plugin as well as the ImageAsset options when saving an image to a file, but neither worked consistently on both platforms(and for both simulators and real devices) to limit the final image width to 400 pixels.  Nativescript abstracts image objects in terms of device independent pixels with scaling for Android and iOS devices to provide programmers with a common interface in Nativescript, but I needed better control of the final dimensions.  Using native code for each platform, I was able to resize the image files to exact dimensions before upload.  

Let's start with the basic profile app from the previous post to illustrate the changes needed to control saved image dimensions for images from both the ImagePicker and Camera plugins. Clone and run the app using:

git clone https://github.com/drangelod/nsvfbprofile nsvfbresize
cd nsvfbresize
npm i
tns platform remove ios
tns run ios --bundle

Since there have been some important updates (notably for the ImagePicker plugin to avoid workarounds and fix some iOS bugs) made since the original posts were released, we will first update the application before getting into new code.

npm install -g nativescript

This will update your main Nativescript CLI to v4.3 as of this post.

tns update

This will update your core modules and the Android and iOS platforms to 5.3.x. We'll also need to update the NPM packages and Nativescript plugins used in this application. I generally use the NCU tool to scan package.json and let me know which modules have updates available. NOTE: Using the -a flag will tell NCU to update all packages to latest versions even if it's a major version change, but be careful as this may introduce errors due to breaking changes. For this application, updating the webpack-related modules will indeed cause problems and require  you to recreate your project using the latest Nativescript Vue template. Instead, we'll only update the Nativescript plugins and platform declarations, and ignore the Vue related updates for now.  

ncu

npm install nativescript-plugin-firebase@latest tns-platform-declarations@latest nativescript-camera@latest nativescript-imagepicker@latest 

tns run ios --bundle

Firebase initialization fix

Another important change should be made for applications using Firebase with Nativescript Vue in order to avoid race conditions between the Firebase auth plugin and NSVue application watch on Firebase login state. Remove or comment out the firebase.init() code block from /main.js. Add a new mounted() property with the firebase initialization code to your export default object in LoginPage.vue so it looks like:

 mounted() {
    let that = this;
    firebase
      .init({
        onAuthStateChanged: data => {
          console.log(
            (data.loggedIn
              ? "Logged in to firebase"
              : "Logged out from firebase") +
              " (firebase.init() onAuthStateChanged callback)"
          );
          if (data.loggedIn) {
            that.$backendService.token = data.user.uid;
            console.log("uID: " + data.user.uid);
            that.$store.commit("setIsLoggedIn", true);
          } else {
            that.$store.commit("setIsLoggedIn", false);
          }
        }
      })
      .then(
        function(instance) {
          console.log("firebase.init done");
        },
        function(error) {
          console.log("firebase.init error: " + error);
        }
      );
  },

This will ensure NSVue is ready to see the login state change from the Firebase Auth plugin and redirect logged-in users to the Dashboard Page properly.

Controlling image dimensions

Below is the original chooseImage() function to handle new profile images selected from the device. Since users can upload whatever images happen to be on their device, I ended up with a very wide range of dimensions and sizes for image posts. This caused storage, display and latency problems with the Nerdaly application, so I added a dimension check to resize large images. If you don't have any images on your current iOS simulator, download a few high resolution images with Safari from a website like NASA to test with later.

    chooseImage() {
      try {
        context
          .authorize()
          .then(() => {
            return context.present();
          })
          .then(selection => {
            loader.show();
            const imageAsset = selection.length > 0 ? selection[0] : null;
            imageAsset.options = {
              width: 400,
              height: 400,
              keepAspectRatio: true
            };
            imageSourceModule
              .fromAsset(imageAsset)
              .then(imageSource => {
                let saved = false;
                let localPath = "";
                let filePath = "";
                let image = {};
                const folderPath = knownFolders.documents().path;
                let fileName =
                  this.$store.state.profile.id +
                  "-" +
                  new Date().getTime() +
                  ".jpg";
                if (imageAsset.android) {
                  localPath = imageAsset.android.toString().split("/");
                  fileName =
                    fileName +
                    "_" +
                    localPath[localPath.length - 1].split(".")[0] +
                    ".jpg";
                  filePath = path.join(folderPath, fileName);
                  saved = imageSource.saveToFile(filePath, "jpeg");
                  if (saved) {
                    this.pictureSource = imageAsset.android.toString();
                  } else {
                    console.log(
                      "Error! Unable to save pic to local file for saving"
                    );
                  }
                  loader.hide();
                } else {
                  const ios = imageAsset.ios;
                  if (ios.mediaType === PHAssetMediaType.Image) {
                    const opt = PHImageRequestOptions.new();
                    opt.version = PHImageRequestOptionsVersion.Current;
                    PHImageManager.defaultManager().requestImageDataForAssetOptionsResultHandler(
                      ios,
                      opt,
                      (imageData, dataUTI, orientation, info) => {
                        image.src = info
                          .objectForKey("PHImageFileURLKey")
                          .toString();
                        localPath = image.src.toString().split("/");
                        fileName =
                          fileName +
                          "_" +
                          localPath[localPath.length - 1].split(".")[0] +
                          ".jpeg";
                        filePath = path.join(folderPath, fileName);
                        saved = imageSource.saveToFile(filePath, "jpeg");

                        if (saved) {
                          this.pictureSource = filePath;
                        } else {
                          console.log(
                            "Error! Unable to save pic to local file for saving"
                          );
                        }
                        loader.hide();
                      }
                    );
                  }
                }
              })
              .catch(err => {
                console.log(err);
                loader.hide();
              });
          })
          .catch(err => {
            console.log(err);
            loader.hide();
          });
      } catch (err) {
        alert("Please select a valid image.");
        console.log(err)
        loader.hide();
      }
    },

We'll need to check the chosen image's dimensions before uploading to the server. The Nativescript ImagePicker plugin returns an ImageAsset (a memory representation of an image in device independant pixels). We won't know the actual image dimensions until the ImageAsset is used to create an ImageSource.  We'll first add a check on image width after the ImageSource is ready but before saving and uploading. If the width exceeds 400 pixels, we will then apply platform native code to resize the image and save it to the device's filesystem, which can then be uploaded to Firebase.

The updated chooseImage() function code section will look like:

    getSampleSize(uri, options) {
      var scale = 1;
      if (isAndroid) {
        var boundsOptions = new android.graphics.BitmapFactory.Options();
        boundsOptions.inJustDecodeBounds = true;
        android.graphics.BitmapFactory.decodeFile(uri, boundsOptions);
        // Find the correct scale value. It should be the power of 2.
        var outWidth = boundsOptions.outWidth;
        var outHeight = boundsOptions.outHeight;
        if (options) {
          var targetSize =
            options.maxWidth < options.maxHeight
              ? options.maxWidth
              : options.maxHeight;
          while (
            !(
              this.matchesSize(targetSize, outWidth) ||
              this.matchesSize(targetSize, outHeight)
            )
          ) {
            outWidth /= 2;
            outHeight /= 2;
            scale *= 2;
          }
        }
      }
      return scale;
    },
    matchesSize(targetSize, actualSize) {
      return targetSize && actualSize / 2 < targetSize;
    },
    chooseImage() {
      let pickcontext = imagepicker.create({ mode: "single" });
      try {
        pickcontext
          .authorize()
          .then(() => {
            return pickcontext.present();
          })
          .then(selection => {
            const imageAsset = selection.length > 0 ? selection[0] : null;
            imageAsset.options = {
              width: 400,
              keepAspectRatio: true,
              autoScaleFactor: false
            };
            loader.show();
            imageSourceModule
              .fromAsset(imageAsset)
              .then(imageSource => {
                var ratio = 400 / imageSource.width;
                var newheight = imageSource.height * ratio;
                var newwidth = imageSource.width * ratio;
                if (imageSource.width > 400) {
                  console.log(
                    "Resizing original image dimentions from : " +
                      imageSource.height +
                      " x " +
                      imageSource.width +
                      " to " +
                      newheight +
                      " x " +
                      newwidth
                  );
                  if (isIOS) {
                    try {
                      let that = this;
                      let manager = PHImageManager.defaultManager();
                      let options = new PHImageRequestOptions();

                      options.resizeMode =
                        PHImageRequestOptionsResizeMode.Exact;
                      options.deliveryMode = PHImageRequestOptionsDeliveryModeHighQualityFormat;
                      manager.requestImageForAssetTargetSizeContentModeOptionsResultHandler(
                        imageAsset.ios,
                        { width: newwidth, height: newheight },
                        PHImageContentModeAspectFill,
                        options,
                        function(result, info) {
                          let saved = false;
                          let filePath = "";
                          const folderPath = knownFolders.documents().path;
                          let fileName =
                            that.$store.state.profile.id +
                            "-" +
                            new Date().getTime() +
                            ".jpg";
                          console.log(
                            "saving image " +
                              fileName +
                              " to path " +
                              folderPath
                          );
                          console.log(
                            "Original image dimentions: " +
                              imageSource.height +
                              " x " +
                              imageSource.width
                          );
                          filePath = path.join(folderPath, fileName);
                          let newasset = new imageAssetModule.ImageAsset(
                            result
                          );

                          imageSourceModule
                            .fromAsset(newasset)
                            .then(newimageSource => {
                              saved = newimageSource.saveToFile(
                                filePath,
                                "jpeg"
                              );
                              if (saved) {
                                that.pictureSource = filePath;
                                that.newFilename = fileName;
                                console.log(
                                  "Resized image imensions: " +
                                    newimageSource.height +
                                    " x " +
                                    newimageSource.width
                                );
                              } else {
                                console.log(
                                  "Error! Unable to save image to local file for saving"
                                );
                              }
                              loader.hide();
                            });
                        }
                      );
                    } catch (e) {
                      console.log("err: " + e);
                      console.log("stack: " + e.stack);
                    }
                  } else if (isAndroid) {
                    try {
                      var downsampleOptions = new android.graphics.BitmapFactory.Options();
                      downsampleOptions.inSampleSize = this.getSampleSize(
                        imageAsset.android,
                        { maxWidth: newwidth, maxHeight: newheight }
                      );
                      var bitmap = android.graphics.BitmapFactory.decodeFile(
                        imageAsset.android,
                        downsampleOptions
                      );
                      imageSource.setNativeSource(bitmap);

                      let filename =
                        this.$store.state.profile.id +
                        "-" +
                        new Date().getTime() +
                        ".jpg";
                      let folder = knownFolders.documents();
                      let fullpath = path.join(folder.path, filename);
                      let saved = imageSource.saveToFile(fullpath, "jpeg");

                      if (saved) {
                        this.pictureSource = fullpath;
                        this.newFilename = filename;
                        console.log(
                          "Resized image imensions: " +
                            imageSource.height +
                            " x " +
                            imageSource.width
                        );
                      } else {
                        console.log(
                          "Error! Unable to save image to local file for saving"
                        );
                      }
                      loader.hide();
                    } catch (err) {
                      console.log(err);
                      loader.hide();
                    }
                  }
                } else {
                  let saved = false;
                  let filePath = "";
                  const folderPath = knownFolders.documents().path;
                  let fileName =
                    this.$store.state.profile.id +
                    "-" +
                    new Date().getTime() +
                    ".jpg";
                  console.log(
                    "saving image " + fileName + " to path " + folderPath
                  );
                  filePath = path.join(folderPath, fileName);
                  saved = imageSource.saveToFile(filePath, "jpeg");

                  if (saved) {
                    this.pictureSource = filePath;
                    this.newFilename = fileName;
                  } else {
                    console.log(
                      "Error! Unable to save image to local file for saving"
                    );
                  }
                  loader.hide();
                }
              })
              .catch(err => {
                console.log(err);
                loader.hide();
              });
          })
          .catch(err => {
            console.log(err);
            loader.hide();
          });
      } catch (err) {
        alert("Please select a valid image.");
        console.log(err);
        loader.hide();
      }
    },

We'll need to change the imports at the top of the script section of code to remove the global context variable (now declared locally in the chooseImage function) and add a new import to use the ImageAsset module. If you run the iOS version and test it with some small and large images, you should see large ones being resized by the application.

Two new functions were also added to help with Android dimension calculations, as it can be a little pickier about the scaled resolution. You can now run the Android version to verify that images are being resized properly for those devices:

tns platform remove android
tns run android --bundle

Resizing camera images

For Android, you can apply the same changes to the takePicture() function for resizing large camera photos before upload to Firebase. For iOS, however, there is a problem using this approach and it dies silently unable to access the source image to resize. Since the iOS camera plugin reliably produces resized images within the maximum dimension requirement, I haven't dug too deeply into exactly why this fails, but I'm guessing it has something to do with sandboxed access to Photo Gallery images on iOS to prevent direct manipulation by platform calls. Adding some extra code to save the image to an accessible temp file and then reloading it before resizing should probably work if you really need full control for this scenario.

The new takePicture() function will look like:

  takePicture() {
      cameraModule
        .takePicture({
          width: 400, //these are in device independent pixels
          keepAspectRatio: true, //    keepAspectRatio is enabled.
          saveToGallery: false //Don't save a copy in local gallery, ignored by some Android devices
        })
        .then(imageAsset => {
          imageAsset.options.autoScaleFactor = false;
          imageAsset.options.keepAspectRatio = true;
          imageAsset.options.width = 400;

          //save to file
          imageSourceModule.fromAsset(imageAsset).then(
            imageSource => {
              var ratio = 400 / imageSource.width;
              var newheight = imageSource.height * ratio;
              var newwidth = imageSource.width * ratio;
              if (imageSource.width > 400) {
                console.log(
                  "Resizing original image dimentions from : " +
                    imageSource.height +
                    " x " +
                    imageSource.width +
                    " to " +
                    newheight +
                    " x " +
                    newwidth
                );
                if (isIOS) {
                  console.log("Ignoring resize for camera images on iOS");
                  let filename =
                    this.$store.state.profile.id +
                    "-" +
                    new Date().getTime() +
                    ".jpg";
                  let folder = knownFolders.documents();
                  let fullpath = path.join(folder.path, filename);
                  let saved = imageSource.saveToFile(fullpath, "jpeg");
                  if (saved) {
                    this.pictureSource = fullpath;
                    this.newFilename = filename;
                    console.log(
                      "image imensions: " +
                        imageSource.height +
                        " x " +
                        imageSource.width
                    );
                  } else {
                    console.log(
                      "Error! Unable to save photo to local file for upload"
                    );
                  }
                } else if (isAndroid) {
                  try {
                    var downsampleOptions = new android.graphics.BitmapFactory.Options();
                    downsampleOptions.inSampleSize = this.getSampleSize(
                      imageAsset.android,
                      { maxWidth: newwidth, maxHeight: newheight }
                    );
                    var bitmap = android.graphics.BitmapFactory.decodeFile(
                      imageAsset.android,
                      downsampleOptions
                    );
                    imageSource.setNativeSource(bitmap);

                    let filename =
                      this.$store.state.profile.id +
                      "-" +
                      new Date().getTime() +
                      ".jpg";
                    let folder = knownFolders.documents();
                    let fullpath = path.join(folder.path, filename);
                    let saved = imageSource.saveToFile(fullpath, "jpeg");

                    if (saved) {
                      this.pictureSource = fullpath;
                      this.newFilename = filename;
                      console.log(
                        "Resized image imensions: " +
                          imageSource.height +
                          " x " +
                          imageSource.width
                      );
                    } else {
                      console.log(
                        "Error! Unable to save image to local file for saving"
                      );
                    }
                    loader.hide();
                  } catch (err) {
                    console.log(err);
                    loader.hide();
                  }
                }
              } else {
                let saved = false;
                let filePath = "";
                const folderPath = knownFolders.documents().path;
                let fileName =
                  this.$store.state.profile.id +
                  "-" +
                  new Date().getTime() +
                  ".jpg";
                console.log(
                  "saving image " + fileName + " to path " + folderPath
                );
                filePath = path.join(folderPath, fileName);
                saved = imageSource.saveToFile(filePath, "jpeg");

                if (saved) {
                  this.pictureSource = filePath;
                  this.newFilename = fileName;
                } else {
                  console.log(
                    "Error! Unable to save image to local file for saving"
                  );
                }
                loader.hide();
              }
            },
            err => {
              console.log("Failed to load from asset");
            }
          );
        })
        .catch(err => {
          console.error(err);
        });
    },

Done!

That's it for this post. If you'd like to download the final source files, you can find them on Github.