TNLExample/TNLXImageSupport.m (381 lines of code) (raw):
//
// TNLXImageSupport.m
// TwitterNetworkLayer
//
// Created on 8/18/14.
// Copyright © 2020 Twitter. All rights reserved.
//
#import "TNLXImageSupport.h"
#if TARGET_OS_IOS
@import UIKit;
#define MAX_CONNECTIONS (-1) // 4
@implementation TNLRequestOperationQueue (Images)
+ (instancetype)tnlx_imageRequestOperationQueue
{
static TNLRequestOperationQueue *sOperationQueue = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sOperationQueue = [[TNLRequestOperationQueue alloc] initWithIdentifier:@"com.twitter.image.operation.queue"];
});
return sOperationQueue;
}
@end
@implementation TNLRequestConfiguration (Images)
+ (instancetype)tnlx_imageRequestConfiguration
{
static TNLRequestConfiguration *sConfig = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
// Prepare a request configuration.
TNLMutableRequestConfiguration *requestConfig = [[TNLMutableRequestConfiguration alloc] init];
requestConfig.responseDataConsumptionMode = TNLResponseDataConsumptionModeStoreInMemory;
requestConfig.idleTimeout = 30;
requestConfig.attemptTimeout = 90;
requestConfig.operationTimeout = 180;
requestConfig.cachePolicy = NSURLRequestReturnCacheDataElseLoad;
requestConfig.URLCache = [NSURLCache tnl_sharedURLCacheProxy];
//requestConfig.URLCache = [NSURLCache tnl_impotentURLCache];
//requestConfig.URLCache = [[NSURLCache alloc] initWithMemoryCapacity:64 * 1024 diskCapacity:64 * 1024 diskPath:[NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject stringByAppendingPathComponent:@"TNLX_Images"]];
#if 1
[requestConfig.URLCache removeAllCachedResponses];
#endif
sConfig = [requestConfig copy];
});
return sConfig;
}
@end
@implementation TNLXImageRequest
+ (dispatch_queue_t)backgroundQueue
{
static dispatch_queue_t sQueue = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sQueue = dispatch_queue_create("image.request.delegate.queue", DISPATCH_QUEUE_SERIAL); // purely for illustration
});
return sQueue;
}
+ (instancetype)imageRequestWithURL:(NSURL *)url
{
return [self imageRequestWithURL:url
desiredDimensions:CGSizeZero
contentMode:UIViewContentModeTopLeft];
}
+ (instancetype)imageRequestWithURL:(NSURL *)url
desiredDimensions:(CGSize)dimensions
contentMode:(UIViewContentMode)contentMode
{
return [[[self class] alloc] initWithURL:url
desiredDimensions:dimensions
contentMode:contentMode];
}
- (instancetype)init
{
return [self initWithURL:nil
desiredDimensions:CGSizeZero
contentMode:UIViewContentModeTopLeft];
}
- (instancetype)initWithURL:(NSURL *)url
desiredDimensions:(CGSize)dimensions
contentMode:(UIViewContentMode)contentMode
{
if (self = [super init]) {
_URL = url;
_dimensions = dimensions;
_contentMode = contentMode;
}
return self;
}
- (BOOL)isEqual:(id)object
{
return [self isEqualToRequest:object];
}
- (BOOL)isEqualToRequest:(id<TNLRequest>)request
{
if (!TNLRequestEqualToRequest(self, request, NO /*quickBodyCheck*/)) {
return NO;
}
UIViewContentMode otherContentMode = UIViewContentModeTopLeft;
CGSize otherDims = CGSizeZero;
if ([request isKindOfClass:[TNLXImageRequest class]]) {
otherContentMode = [(TNLXImageRequest *)request contentMode];
otherDims = [(TNLXImageRequest *)request dimensions];
}
return self.contentMode == otherContentMode && CGSizeEqualToSize(self.dimensions, otherDims);
}
- (id)copyWithZone:(NSZone *)zone
{
return self;
}
@end
@implementation TNLXImageResponse
- (void)prepare
{
[super prepare];
[self populateImage];
}
- (void)populateImage
{
id<TNLRequest> request = self.originalRequest;
UIViewContentMode mode = UIViewContentModeTopLeft;
CGSize desiredDims = CGSizeZero;
if ([request isKindOfClass:[TNLXImageRequest class]]) {
mode = [(TNLXImageRequest *)request contentMode];
desiredDims = [(TNLXImageRequest *)request dimensions];
}
_requestContentMode = mode;
_requestImageDimensions = desiredDims;
if (!self.operationError) {
NSError *error = nil;
if (!self.info.data) {
error = [NSError errorWithDomain:NSStringFromClass([self class]) code:-1 userInfo:nil];
} else {
UIImage *image = [UIImage imageWithData:self.info.data];
if (!image) {
error = [NSError errorWithDomain:NSStringFromClass([self class]) code:-2 userInfo:nil];
} else {
CGSize currentDims = image.size;
currentDims.width *= image.scale;
currentDims.height *= image.scale;
_rawImageDimensions = currentDims;
CGSize scaledDims = TNLXSizeScale(currentDims, desiredDims, mode);
_scaledImageDimensions = scaledDims;
UIGraphicsBeginImageContextWithOptions(scaledDims, NO, 1.0);
CGRect scaledImageRect = (CGRect){ CGPointZero, scaledDims };
[image drawInRect:scaledImageRect];
image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
_image = image;
}
}
if (error) {
NSLog(@"%@", error);
_operationError = error;
}
}
}
@end
@interface TNLXImageView () <TNLRequestDelegate>
@end
@implementation TNLXImageView
{
TNLRequestOperation *_imageOp;
UIProgressView *_progressView;
UILabel *_loadingURLLabel;
TNLMutableRequestConfiguration *_config;
NSURL *_selectedImageURL;
}
@synthesize imageLoadOperation = _imageOp;
- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
_progressView = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleBar];
frame.size.width -= 40;
frame.origin.x = 20;
frame.origin.y = (CGFloat)round(((frame.size.height - _progressView.frame.size.height) / 2.0));
frame.size.height = _progressView.frame.size.height;
_progressView.frame = frame;
_progressView.transform = CGAffineTransformMakeScale(1.0f, 22.0f / frame.size.height);
_progressView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleBottomMargin;
_progressView.backgroundColor = [UIColor darkTextColor];
[self addSubview:_progressView];
_progressView.hidden = YES;
// frame.origin.y += 3.0;
frame.size.height = 22;
_loadingURLLabel = [[UILabel alloc] initWithFrame:frame];
_loadingURLLabel.font = [UIFont systemFontOfSize:16];
_loadingURLLabel.textColor = [UIColor whiteColor];
_loadingURLLabel.textAlignment = NSTextAlignmentCenter;
_loadingURLLabel.lineBreakMode = NSLineBreakByTruncatingMiddle;
_loadingURLLabel.numberOfLines = 1;
_loadingURLLabel.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleBottomMargin;
[self addSubview:_loadingURLLabel];
_loadingURLLabel.hidden = YES;
_progressView.center = _loadingURLLabel.center;
_config = [[TNLRequestConfiguration tnlx_imageRequestConfiguration] mutableCopy];
}
return self;
}
#pragma mark - TNLRequestDelegate
- (dispatch_queue_t)tnl_delegateQueueForRequestOperation:(TNLRequestOperation *)op
{
return [TNLXImageRequest backgroundQueue];
}
#pragma mark - TNLRequestEventHandler
- (void)tnl_requestOperation:(TNLRequestOperation *)op didUpdateDownloadProgress:(float)downloadProgress
{
if ([NSThread isMainThread]) {
if (op == _imageOp) {
[_progressView setProgress:downloadProgress animated:YES];
}
} else {
dispatch_async(dispatch_get_main_queue(), ^{
[self tnl_requestOperation:op didUpdateDownloadProgress:downloadProgress];
});
}
}
- (void)tnl_requestOperation:(TNLRequestOperation *)op didCompleteWithResponse:(TNLXImageResponse *)response
{
NSURL *responseURL = op.hydratedRequest.URL;
UIImage *image = response.image;
float progress = op.downloadProgress;
assert([NSThread isMainThread]);
_imageOp = nil;
if ([_selectedImageURL isEqual:responseURL]) {
self.image = image;
if (!image) {
NSString *errorText = nil;
if (errorText.length == 0 && response.operationError) {
errorText = response.operationError.description;
}
if (errorText.length == 0 && !TNLHTTPStatusCodeIsSuccess(response.info.statusCode)) {
errorText = [NSString stringWithFormat:@"HTTP Status == %li", (long)response.info.statusCode];
}
if (errorText.length == 0) {
errorText = @"ERROR";
}
_loadingURLLabel.text = errorText;
_loadingURLLabel.hidden = NO;
_progressView.progress = progress;
_progressView.hidden = NO;
}
}
}
#pragma mark View Methods
- (void)setImage:(UIImage *)image
{
[self _cancelLoad];
[super setImage:image];
}
- (void)setImageEntity:(id<TAPIImageEntityModel>)imageEntity
{
CGSize dims = self.bounds.size;
dims.width *= [UIScreen mainScreen].scale;
dims.height *= [UIScreen mainScreen].scale;
NSURL *bestURL = TNLXSelectBestImageURL(imageEntity, dims, self.contentMode);
const BOOL different = ![_selectedImageURL isEqual:bestURL];
_imageEntity = imageEntity;
_selectedImageURL = bestURL;
if (different) {
self.image = nil;
_loadingURLLabel.text = bestURL.absoluteString;
[self _load];
}
}
- (void)layoutSubviews
{
[super layoutSubviews];
self.imageEntity = _imageEntity;
}
- (void)didMoveToWindow
{
if (self.window) {
[self _load];
}
}
- (void)_cancelLoad
{
[_imageOp cancelWithSource:NSStringFromSelector(_cmd)];
_imageOp = nil;
_progressView.hidden = YES;
_progressView.progress = 0;
_loadingURLLabel.hidden = YES;
}
- (void)_load
{
if (_selectedImageURL && !_imageOp && !self.image && self.window) {
_progressView.hidden = NO;
_progressView.progress = 0;
_loadingURLLabel.hidden = NO;
CGSize dims = self.bounds.size;
dims.width *= [UIScreen mainScreen].scale;
dims.height *= [UIScreen mainScreen].scale;
TNLXImageRequest *request = [[TNLXImageRequest alloc] initWithURL:_selectedImageURL
desiredDimensions:dims
contentMode:self.contentMode];
_imageOp = [TNLRequestOperation operationWithRequest:request
responseClass:[TNLXImageResponse class]
configuration:_config
delegate:self];
_imageOp.priority = TNLPriorityNormal;
[[TNLRequestOperationQueue tnlx_imageRequestOperationQueue] enqueueRequestOperation:_imageOp];
}
}
- (void)willMoveToWindow:(UIWindow *)newWindow
{
[super willMoveToWindow:newWindow];
_imageOp.priority = (newWindow != nil) ? TNLPriorityNormal : TNLPriorityLow;
}
- (void)dealloc
{
[self _cancelLoad];
}
@end
static id<TAPIImageEntityVariantModel> _SelectBestImageVariant(id<TAPIImageEntityModel> model, CGSize targetDimensions, UIViewContentMode targetContentMode)
{
if (targetDimensions.width <= 0 || targetDimensions.height <= 0) {
return model.variants.lastObject;
}
id<TAPIImageEntityVariantModel> selectedVariant = nil;
for (NSUInteger idx = 0; idx < model.variants.count && !selectedVariant; idx++) {
id<TAPIImageEntityVariantModel> variant = model.variants[idx];
switch (targetContentMode) {
case UIViewContentModeScaleAspectFit:
{
if (variant.dimensions.width >= targetDimensions.width || variant.dimensions.height >= targetDimensions.height) {
selectedVariant = variant;
}
break;
}
case UIViewContentModeScaleAspectFill:
{
if (variant.dimensions.width >= targetDimensions.width && variant.dimensions.height >= targetDimensions.height) {
selectedVariant = variant;
}
break;
}
default:
{
if (variant.dimensions.width >= targetDimensions.width || variant.dimensions.height >= targetDimensions.height) {
if (idx > 0) {
idx--;
}
selectedVariant = model.variants[idx];
}
break;
}
}
}
return selectedVariant ?: model.variants.lastObject;
}
NSURL *TNLXSelectBestImageURL(id<TAPIImageEntityModel> model, CGSize targetDimensions, UIViewContentMode targetContentMode)
{
id<TAPIImageEntityVariantModel> variant = _SelectBestImageVariant(model, targetDimensions, targetContentMode);
NSString *URLString = [NSString stringWithFormat:@"%@?format=%@&name=%@", model.baseURLString, model.format, variant.name];
return [NSURL URLWithString:URLString];
}
CGSize TNLXSizeScale(CGSize sourceSize, CGSize desiredSize, UIViewContentMode contentMode)
{
switch (contentMode) {
case UIViewContentModeScaleToFill:
return desiredSize;
case UIViewContentModeScaleAspectFit:
case UIViewContentModeScaleAspectFill:
{
CGFloat widthRatio = sourceSize.width / desiredSize.width;
CGFloat heightRatio = sourceSize.height / desiredSize.height;
if (UIViewContentModeScaleAspectFit == contentMode) {
if (heightRatio > widthRatio) {
widthRatio = heightRatio;
}
} else {
if (heightRatio < widthRatio) {
widthRatio = heightRatio;
}
}
desiredSize.width = (CGFloat)floor(sourceSize.width * 2.0f / widthRatio) / 2.0f;
desiredSize.height = (CGFloat)floor(sourceSize.height * 2.0f / widthRatio) / 2.0f;
return desiredSize;
}
case UIViewContentModeRedraw:
case UIViewContentModeCenter:
case UIViewContentModeTop:
case UIViewContentModeBottom:
case UIViewContentModeLeft:
case UIViewContentModeRight:
case UIViewContentModeTopLeft:
case UIViewContentModeTopRight:
case UIViewContentModeBottomLeft:
case UIViewContentModeBottomRight:
return sourceSize;
}
}
#endif