2

I am trying to load the phone's gallery (with pagination) in a GridView.builder widget.

Here is the issue i have created using the photo_manager package.
I have got some help and it made me think about a possible solution (see my last comment on the issue).

I would like to be able to load the assets without blinking or white page.
On IOS native it's veeery fast and smooth, i want to achieve the same in Flutter.

You will find all the pieces of code i have made in the github link above. I have managed to do so using a Map object in memory but i need to improve the algorithm to not be in OOM.

Solutions wanted (one or the other) :

  • A simple way to do this, load the phone's gallery as fast as the native IOS into a GridView, no matter which package is used the time it's working.
  • An improvement of my currently poor algorithm that would keep for example the 15 assets above the current one, 15 assets below in memory and during the scroll, keep updating these values to move the range around the current position in the list.

Please let me know if this is not clear enough, as a reminder please have a look at my last big comment on this issue.

Tom3652
  • 2,540
  • 3
  • 19
  • 45
  • Before asking to close because it "needs more focus", please specify in the comment what is not clear and i will update the question. The use case is very simple, i just need the phone's gallery to be loaded as fast as the IOS native gallery. – Tom3652 Oct 21 '21 at 09:53
  • If you just need to create an image gallery as an image picker, have you considered using [image_picker](https://pub.dev/packages/image_picker) plugin? – Omatt Oct 26 '21 at 11:18
  • I need to customize the Gallery. This means i have to build a custom UI page that will load the items + other UI specifications. ```image_picker``` works well but it opens a new ```Intent``` / new page that is not customizable. If i am wrong, please feel free to write an answer. – Tom3652 Oct 26 '21 at 13:49

2 Answers2

1

You can use a logic like this :

final Map<String, Uint8List?> _cachedMap = {};
void precacheAssets(int index) async {
    // Handle cache before index
    for (int i = max(0, index - 50); i < 50; i++) {
      getItemAtIndex(i);
    }
    // Handle cache after index
    for (int i = min(assetsList.length, index + 50); i < 50 + min(assetsList.length, index + 50); i++) {
      getItemAtIndex(i);
    }
    _cachedMap.removeWhere((key, value) {
      int currIndex = assetsList.indexWhere((element) => element.id == key);
      return currIndex < index - 50 && currIndex > index + 50;
    });
  }
  /// Get the asset from memory or fetch it if it doesn’t exist yet.
  /// Called in the builder method to display assets, not to precache them.
  Future<Uint8List?> getItemAtIndex(int index) async {
    AssetEntity entity = assetsList[index];
    if (_cachedMap.containsKey(entity.id)) {
      return _cachedMap[entity.id];
    }
    else {
      Uint8List? thumb = await entity.thumbDataWithOption(
          ThumbOption.ios(
              width: width,
              height: height,
              deliveryMode: DeliveryMode.highQualityFormat,
              quality: 90));
      _cachedMap[entity.id] = thumb;
      return thumb;
    }
  }

And you can call the precacheAssets method in your GridView.builder at a specific index for example if (index % 25 == 0) which will tell every 25 items, put in cache the 50 next ones so it will add 25 more items to the existing cache.

Also, call the getItemAtIndex in your Future.builder as future param and you will get instantly the asset if it’s in memory, otherwise load it as usual.

Feel free to change the values and test it, it’s already improved with these values in my iPhone but if you are scrolling VERY fast you will still see as before a bit.

You can add a FadeTransition in this case which will result in a non-ugly UI.

nicover
  • 2,213
  • 10
  • 24
  • 1
    Thanks for your answer. Since it still doesn't load as fast as native IOS perfectly, i don't set it as an accepted answer yet in case someone finds a better way, but i am tuning a bit your example and i have something really satisfying ! – Tom3652 Nov 01 '21 at 13:20
0

The solution is quite simple: populate your GridView with thumbs instead of large images:

// Load [AssetEntity]s:
images = await album.getAssetListRange(start: 0, end: 50);

// Then build [GridView]:
GridView.builder(
  gridDelegate:
      const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 4,
    crossAxisSpacing: 3,
    mainAxisSpacing: 3,
  ),
  itemCount: images.length,
  itemBuilder: (context, index) {
    final image = images[index];
    return Image(
      image: DeviceImage(
        image,
        size: const Size(200, 200),
      ),
      fit: BoxFit.cover,
    );
  },
);

As you can see, I pass AssetEntity to DeviceImage provider. DeviceImage loads raw bytes of size (200, 200), and Image displays the thumb.

Here's DeviceImage code, I built it based on local_image_provider:

import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui' as ui show Codec;
import 'dart:ui';

import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/services.dart';
import 'package:photo_manager/photo_manager.dart';

/// Decodes the given [LocalImage] object as an [ImageProvider], associating it with the given
/// scale.
///
///
/// In general only the constructor of this object should be used directly,
/// after that use the resulting object wherever an [ImageProvider] is
/// needed in Flutter. In particular with an [Image] widget.
///
class DeviceImage extends ImageProvider<DeviceImage> {
  static const int _kMaxSize = 1200;
  static const String _kNoImageBase64 =
      "iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAIAAABLixI0AAAAA3NCSVQICAjb4U/gAAADCUlEQVQ4ja2VT0zTYBTAX9dttB3b6AzFAEIlgEaH7gRxHIyBYGIkGMNAQpaBCdEYjiacPHD0QCQSlcSL8aaZhIN/4xBFlkVj5mY0hgMskwDyt5SNtdBu9VDp1m6AGt/p/fm+33vv+16/IpIkwX8S/d7hZCy2NTMtzs/rS0ryDlegZvPfs1IpZsQbuzeYYlcz3Yi5wNx1lex0I/ocG5HsHoWlxcUr3eLs9G75dYXF1NB9rLJyH5YkCHOdLnHme9qF6nU2SoqxEr+Zxh04WPLkGZqfr8qhYa/cvpUG6VBLb1+5P1z2Yrx8Mlh496GuoFCOpFZ/Lvff0NabafA8n3j9XDFN167j7R28IHAcx3GcruYEOXAnvTjwbovjcvSYSCS8Xi83NXX2lVcObOkMo+fbthFEk9z19BGWFGR9vKEFPW5vbW0lCAKUewyFQtFoFDBstLmDjMdNsY0UimaDiriEAkoCEsNxNhoNhUJOpzPNYhhGVqiq6tra2rW1Nb/fD/F4JsiYStmDHxSTMZMsqs/cqxoTDMNcLpfBYAAAk8k0MjKihMxJsf79WCG7LJsSwGzVMU3VKlZxcbEMAgCaptOgVPL0m5dkYl0Bfaup+3qIBrWoWJFIhGVZq9UKAOFwWGnNOeFTQADIRsulz4BClqhYkiQNDw/b7XaGYSKRiOys/zhJ7bQGANa+ftrVvuzzBQIBDUs7q9vb28FgcGFhQa6uQBRLF38oUUtvH+lqB4DGxsa6urp9WABAEITH4+np6aEoqmhlSfFjZ87Zui4rZlNTE4Zhe7EIgnC73RRF4TjudrtLd6YJAIw1JzWLEfUAqs4LRVEZpHCPnqrfnIvKZl71kewmdmUZjUYFJIvN023zdO+NyM0SBEFzO5a3Y8aJMVkX7Y71i22a9TlYJEkCgCiKPp8vM1wf+lSxPC/rK1/AZ7FllyPvBeXsHQ5H5qD/udA07XA4ZF31rvI8r3lm2aFB/vEDWTc4G2w3BzKjCIJkjoX229akRZov8FW/r89QVobj+B415vh3/LP8AvvVK04ZJmjyAAAAAElFTkSuQmCC";
  static final Uint8List noImageBytes = base64Decode(_kNoImageBase64);

  /// Creates an object that decodes a [LocalImage] as an image.
  ///
  /// The arguments must not be null. [scale] returns a scaled down
  /// version of the image. For example to load a thumbnail you could
  /// use something like .1 as the scale. There's a convenience method
  /// on [LocalImage] that can calculate the scale for a given pixel
  /// size.
  /// [minPixels] can be used to specify a minimum independent of the
  /// requested scale. The idea is that scaling an image that you don't know
  /// the original size of can result in some results that are too small.
  /// If the goal is to display the image in a 50x50 thumbnail then you might
  /// want to set 50 as the minPixels, then regardless of the image size and
  /// scale you'll get at least 50 pixels in each dimension. This parameter
  /// was added as a result of a strange result in iOS where an image with
  /// a portrait aspect ratio was failing to load when scaled below 120 pixels.
  /// Setting 150 as the minimum in this case resolved the problem.
  const DeviceImage(this.assetEntity,
      {this.scale = 1.0, this.minPixels = 0, this.quality = 70, this.size});

  /// The LocalImage to decode into an image.
  final AssetEntity assetEntity;

  /// The scale to place in the [ImageInfo] object of the image.
  final double scale;

  /// The minPixels to place in the [ImageInfo] object of the image.
  final int minPixels;

  /// Optional image quality (0-100), default is set to 70.
  final int quality;

  /// Optional image size. If null, then full size will be loaded.
  final Size? size;

  @override
  Future<DeviceImage> obtainKey(ImageConfiguration? configuration) {
    return SynchronousFuture<DeviceImage>(this);
  }

  @override
  ImageStreamCompleter load(DeviceImage key, DecoderCallback decode) {
    return MultiFrameImageStreamCompleter(
      codec: _loadAsync(key, decode),
      scale: key.scale,
      informationCollector: () sync* {
        yield ErrorDescription('Id: ${assetEntity.id}');
      },
    );
  }

  @override
  void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream,
      DeviceImage key, ImageErrorListener handleError) {
    if (shouldCache()) {
      super.resolveStreamForKey(configuration, stream, key, handleError);
      return;
    }
    final ImageStreamCompleter completer =
        load(key, PaintingBinding.instance!.instantiateImageCodec);
    stream.setCompleter(completer);
  }

  int get height => max((assetEntity.height * scale).round(), minPixels);
  int get width => max((assetEntity.width * scale).round(), minPixels);

  @visibleForTesting
  bool shouldCache() {
    return size == null;
  }

  Future<ui.Codec> _loadAsync(DeviceImage key, DecoderCallback decoder) async {
    assert(key == this);
    try {
      final int width;
      final int height;
      if (size == null) {
        width = _kMaxSize;
        height = _kMaxSize;
      } else {
        width = size!.width.toInt();
        height = size!.height.toInt();
      }
      final bytes = await assetEntity.thumbDataWithSize(
        width,
        height,
        quality: quality,
      );
      if (bytes == null || bytes.lengthInBytes == 0) {
        return decoder(noImageBytes);
      }

      return await decoder(bytes);
    } on PlatformException {
      return await decoder(noImageBytes);
    }
  }

  @override
  bool operator ==(dynamic other) {
    if (other.runtimeType != runtimeType) return false;
    final DeviceImage typedOther = other;
    return assetEntity.id == typedOther.assetEntity.id &&
        scale == typedOther.scale;
  }

  @override
  int get hashCode => assetEntity.hashCode;

  @override
  String toString() => '$runtimeType($assetEntity, scale: $scale)';
}

Note: the code is not production-ready.

Andrey Gordeev
  • 30,606
  • 13
  • 135
  • 162
  • Thanks for taking time to reply. However it doesn't work as expected. Have you checked my github issue ? I am already using thumbnails even 64x64 to test and it doesn't work. I have actually also provided a begining of solution on the Github. With yours, i am seeing the same as i have requested on Github :( I have checked your code, and you are using the same methods as i am and items take too much time to be loaded in the ```GridView``` unfortunately :/. What i need is a fluent user experience without empty screens for few ```milliseconds``` when scrolling very fast. – Tom3652 Oct 29 '21 at 17:36
  • Then you need to precache thumbnails in the background after the page is opened, using `album.getAssetListRange` – Andrey Gordeev Oct 30 '21 at 04:14
  • You are right and that's what i have started to do, this question is about an implementation of this solution actually, working with 5k + assets and precaching only 20-30 maximum ahead (scrolling up + down) to minimize memory load and avoid OOM. – Tom3652 Oct 30 '21 at 09:43