Files
MapLibreParticleLab/platform/ios/src/MLNScaleBar.mm
mwilsnd 20d9e1028c Platform flattening overhaul (#1393)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Bart Louwers <bart@emeel.net>
2023-07-26 17:50:34 +02:00

630 lines
20 KiB
Plaintext

#import "Mapbox.h"
#import "MLNScaleBar.h"
static const CGFloat MLNFeetPerMile = 5280;
struct MLNRow {
CLLocationDistance distance;
NSUInteger numberOfBars;
};
static const MLNRow MLNMetricTable[] = {
{.distance = 1, .numberOfBars = 2},
{.distance = 2, .numberOfBars = 2},
{.distance = 4, .numberOfBars = 2},
{.distance = 10, .numberOfBars = 2},
{.distance = 20, .numberOfBars = 2},
{.distance = 50, .numberOfBars = 2},
{.distance = 75, .numberOfBars = 3},
{.distance = 100, .numberOfBars = 2},
{.distance = 150, .numberOfBars = 2},
{.distance = 200, .numberOfBars = 2},
{.distance = 300, .numberOfBars = 3},
{.distance = 500, .numberOfBars = 2},
{.distance = 1000, .numberOfBars = 2},
{.distance = 1500, .numberOfBars = 2},
{.distance = 3000, .numberOfBars = 3},
{.distance = 5000, .numberOfBars = 2},
{.distance = 10000, .numberOfBars = 2},
{.distance = 20000, .numberOfBars = 2},
{.distance = 30000, .numberOfBars = 3},
{.distance = 50000, .numberOfBars = 2},
{.distance = 100000, .numberOfBars = 2},
{.distance = 200000, .numberOfBars = 2},
{.distance = 300000, .numberOfBars = 3},
{.distance = 400000, .numberOfBars = 2},
{.distance = 500000, .numberOfBars = 2},
{.distance = 600000, .numberOfBars = 3},
{.distance = 800000, .numberOfBars = 2},
};
static const MLNRow MLNImperialTable[] ={
{.distance = 4, .numberOfBars = 2},
{.distance = 6, .numberOfBars = 2},
{.distance = 10, .numberOfBars = 2},
{.distance = 20, .numberOfBars = 2},
{.distance = 30, .numberOfBars = 2},
{.distance = 50, .numberOfBars = 2},
{.distance = 75, .numberOfBars = 3},
{.distance = 100, .numberOfBars = 2},
{.distance = 200, .numberOfBars = 2},
{.distance = 300, .numberOfBars = 3},
{.distance = 400, .numberOfBars = 2},
{.distance = 600, .numberOfBars = 3},
{.distance = 800, .numberOfBars = 2},
{.distance = 1000, .numberOfBars = 2},
{.distance = 0.25f*MLNFeetPerMile, .numberOfBars = 2},
{.distance = 0.5f*MLNFeetPerMile, .numberOfBars = 2},
{.distance = 1*MLNFeetPerMile, .numberOfBars = 2},
{.distance = 2*MLNFeetPerMile, .numberOfBars = 2},
{.distance = 3*MLNFeetPerMile, .numberOfBars = 3},
{.distance = 4*MLNFeetPerMile, .numberOfBars = 2},
{.distance = 8*MLNFeetPerMile, .numberOfBars = 2},
{.distance = 12*MLNFeetPerMile, .numberOfBars = 2},
{.distance = 15*MLNFeetPerMile, .numberOfBars = 3},
{.distance = 20*MLNFeetPerMile, .numberOfBars = 2},
{.distance = 30*MLNFeetPerMile, .numberOfBars = 3},
{.distance = 40*MLNFeetPerMile, .numberOfBars = 2},
{.distance = 80*MLNFeetPerMile, .numberOfBars = 2},
{.distance = 120*MLNFeetPerMile, .numberOfBars = 2},
{.distance = 200*MLNFeetPerMile, .numberOfBars = 2},
{.distance = 300*MLNFeetPerMile, .numberOfBars = 3},
{.distance = 400*MLNFeetPerMile, .numberOfBars = 2},
};
@class MLNScaleBarLabel;
@interface MLNScaleBar()
@property (nonatomic) NSArray<UIView *> *labelViews;
@property (nonatomic) NSArray<UIView *> *bars;
@property (nonatomic) UIView *containerView;
@property (nonatomic) MLNDistanceFormatter *formatter;
@property (nonatomic, assign) MLNRow row;
@property (nonatomic) UIColor *primaryColor;
@property (nonatomic) UIColor *secondaryColor;
@property (nonatomic, assign) CGFloat borderWidth;
@property (nonatomic) NSMutableDictionary* labelImageCache;
@property (nonatomic) MLNScaleBarLabel* prototypeLabel;
@property (nonatomic) CGFloat lastLabelWidth;
@property (nonatomic) CGSize size;
@property (nonatomic) BOOL recalculateSize;
@property (nonatomic) BOOL shouldLayoutBars;
@property (nonatomic) NSNumber *testingRightToLeftOverride;
@end
static const CGFloat MLNBarHeight = 4;
static const CGFloat MLNFeetPerMeter = 3.28084;
static const CGFloat MLNScaleBarLabelWidthHint = 30.0;
static const CGFloat MLNScaleBarMinimumBarWidth = 30.0; // Arbitrary
@interface MLNScaleBarLabel : UILabel
@property (nonatomic) BOOL shouldShowDarkStyles;
@end
@implementation MLNScaleBarLabel
- (void)setShouldShowDarkStyles:(BOOL)shouldShowDarkStyles {
if (_shouldShowDarkStyles != shouldShowDarkStyles) {
_shouldShowDarkStyles = shouldShowDarkStyles;
// Redraw labels
[self setNeedsDisplay];
}
}
- (void)drawTextInRect:(CGRect)rect {
CGSize shadowOffset = self.shadowOffset;
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSetLineWidth(context, 2);
CGContextSetLineJoin(context, kCGLineJoinRound);
CGContextSetTextDrawingMode(context, kCGTextStroke);
if (_shouldShowDarkStyles) {
self.textColor = [UIColor blackColor];
} else {
self.textColor = [UIColor whiteColor];
}
[super drawTextInRect:rect];
CGContextSetTextDrawingMode(context, kCGTextFill);
if (self.shouldShowDarkStyles) {
self.textColor = [UIColor whiteColor];
} else {
self.textColor = [UIColor blackColor];
}
self.shadowOffset = CGSizeMake(0, 0);
[super drawTextInRect:rect];
self.shadowOffset = shadowOffset;
}
@end
@implementation MLNScaleBar
- (instancetype)initWithCoder:(NSCoder *)decoder {
if (self = [super initWithCoder:decoder]) {
[self commonInit];
}
return self;
}
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
[self commonInit];
}
return self;
}
- (void)commonInit {
_size = CGSizeZero;
_primaryColor = [UIColor colorWithRed:18.0/255.0 green:45.0/255.0 blue:17.0/255.0 alpha:1];
_secondaryColor = [UIColor colorWithRed:247.0/255.0 green:247.0/255.0 blue:247.0/255.0 alpha:1];
_borderWidth = 1.0f;
self.clipsToBounds = NO;
self.hidden = YES;
// Default to current local
NSLocale *locale = [NSLocale currentLocale];
_usesMetricSystem = [[locale objectForKey:NSLocaleUsesMetricSystem] boolValue];
_containerView = [[UIView alloc] init];
_containerView.clipsToBounds = YES;
_containerView.backgroundColor = _secondaryColor;
_containerView.layer.borderColor = _primaryColor.CGColor;
_containerView.layer.borderWidth = _borderWidth / [[UIScreen mainScreen] scale];
_containerView.layer.cornerRadius = MLNBarHeight / 2.0;
_containerView.layer.masksToBounds = YES;
[self addSubview:_containerView];
_formatter = [[MLNDistanceFormatter alloc] init];
// Image labels are now images
_labelImageCache = [[NSMutableDictionary alloc] init];
_prototypeLabel = [[MLNScaleBarLabel alloc] init];
_prototypeLabel.font = [UIFont systemFontOfSize:8 weight:UIFontWeightMedium];
_prototypeLabel.clipsToBounds = NO;
_prototypeLabel.shouldShowDarkStyles = _shouldShowDarkStyles;
NSUInteger numberOfLabels = 4;
NSMutableArray *labelViews = [NSMutableArray arrayWithCapacity:numberOfLabels];
for (NSUInteger i = 0; i < numberOfLabels; i++) {
UIView *view = [[UIView alloc] init];
view.bounds = CGRectZero;
view.clipsToBounds = NO;
view.contentMode = UIViewContentModeCenter;
view.hidden = YES;
[labelViews addObject:view];
[self addSubview:view];
}
_labelViews = [labelViews copy];
_lastLabelWidth = MLNScaleBarLabelWidthHint;
// Zero is a special case (no formatting)
[self addZeroLabel];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(resetLabelImageCache) name:NSCurrentLocaleDidChangeNotification object:nil];
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (void)resetLabelImageCache {
self.labelImageCache = [[NSMutableDictionary alloc] init];
[self addZeroLabel];
}
// MARK: - Dimensions
- (void)setBorderWidth:(CGFloat)borderWidth {
_borderWidth = borderWidth;
_containerView.layer.borderWidth = borderWidth / [[UIScreen mainScreen] scale];
}
// Determines the width of the bars NOT the size of the entire scale bar,
// which includes space for (half) a label.
// Uses the current set `row`
- (CGFloat)actualWidth {
CGFloat unitsPerPoint = [self unitsPerPoint];
if (unitsPerPoint == 0.0) {
return 0.0;
}
CGFloat width = self.row.distance / unitsPerPoint;
if (width <= MLNScaleBarMinimumBarWidth) {
return 0.0;
}
// Round, so that each bar section has an integer width
return self.row.numberOfBars * floor(width/self.row.numberOfBars);
}
- (CGFloat)maximumWidth {
// TODO: Consider taking Scale Bar margins into account here.
CGFloat fullWidth = CGRectGetWidth(self.superview.bounds);
return floorf(fullWidth / 2);
}
- (CGFloat)unitsPerPoint {
return [self usesMetricSystem] ? self.metersPerPoint : self.metersPerPoint * MLNFeetPerMeter;
}
// MARK: - Convenience methods
- (BOOL)usesRightToLeftLayout {
if (self.testingRightToLeftOverride) {
return [self.testingRightToLeftOverride boolValue];
}
return [UIView userInterfaceLayoutDirectionForSemanticContentAttribute:self.superview.semanticContentAttribute] == UIUserInterfaceLayoutDirectionRightToLeft;
}
- (MLNRow)preferredRow {
CLLocationDistance maximumDistance = [self maximumWidth] * [self unitsPerPoint];
BOOL useMetric = [self usesMetricSystem];
const MLNRow *row;
const MLNRow *table;
NSUInteger count;
if (useMetric) {
row = table = MLNMetricTable;
count = sizeof(MLNMetricTable) / sizeof(MLNMetricTable[0]);
}
else {
row = table = MLNImperialTable;
count = sizeof(MLNImperialTable) / sizeof(MLNImperialTable[0]);
}
while (row < table + count) {
if (row->distance > maximumDistance) {
// use the previous row
NSAssert(row != table, @"");
return *(row - 1);
}
++row;
}
// Didn't find it, just return the first.
return *table;
}
// MARK: - Dark Mode Changes
- (void)setShouldShowDarkStyles:(BOOL)shouldShowDarkStyles {
if (_shouldShowDarkStyles != shouldShowDarkStyles) {
_shouldShowDarkStyles = shouldShowDarkStyles;
// Redraw labels
_prototypeLabel.shouldShowDarkStyles = shouldShowDarkStyles;
[self resetLabelImageCache];
[self updateLabels];
}
}
// MARK: - Setters
- (void)setUsesMetricSystem:(BOOL)usesMetricSystem {
if (_usesMetricSystem != usesMetricSystem) {
_usesMetricSystem = usesMetricSystem;
// Find the current locale for the system, then check if we need to override this. E.g. Locale is in the United States but we want to see metric.
NSLocale *locale = [NSLocale currentLocale];
BOOL currentLocaleUsesMetric = [[locale objectForKey:NSLocaleUsesMetricSystem] boolValue];
if (currentLocaleUsesMetric && !usesMetricSystem) {
// Our current locale is metric, but for some reason we don't want to show metric here, so we need to force a locale that isn't metric.
self.formatter.numberFormatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US"];
} else if (!currentLocaleUsesMetric && usesMetricSystem) {
// Our current locale is not metric, but we want to use the metric system for the scale bar.
// Use Canada as the identifier as they use a period as a decimal seperator
self.formatter.numberFormatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_CA"];
} else if ((currentLocaleUsesMetric && usesMetricSystem) || (!currentLocaleUsesMetric && !usesMetricSystem)) {
// Fallback to the system locale.
self.formatter.numberFormatter.locale = locale;
}
[self resetLabelImageCache];
[self updateVisibility];
self.recalculateSize = YES;
[self invalidateIntrinsicContentSize];
}
}
- (void)setMetersPerPoint:(CLLocationDistance)metersPerPoint {
if (_metersPerPoint == metersPerPoint) {
return;
}
_metersPerPoint = metersPerPoint;
[self updateVisibility];
self.recalculateSize = YES;
[self invalidateIntrinsicContentSize];
}
- (CGSize)intrinsicContentSize {
// Size is calculated elsewhere - since intrinsicContentSize is part of the
// constraint system, this should be done in updateConstraints
if (self.size.width < 0.0) {
return CGSizeZero;
}
return self.size;
}
/// updateConstraints
///
/// The primary job of updateConstraints here is to recalculate the
/// intrinsicContentSize: _metersPerPoint and the maximum width determine the
/// current "row", which in turn determines the "actualWidth". To obtain the full
/// width of the scale bar, we also need to include some space for the "last"
/// label
- (void)updateConstraints {
if (self.isHidden || !self.recalculateSize) {
[super updateConstraints];
return;
}
// TODO: Improve this (and the side-effects)
self.row = [self preferredRow];
NSAssert(self.row.numberOfBars > 0, @"");
CGFloat totalBarWidth = self.actualWidth;
if (totalBarWidth <= 0.0) {
[super updateConstraints];
return;
}
// Determine the "lastLabelWidth". This has changed to take a maximum of each
// label, to ensure that the size does not change in LTR & RTL layouts, and
// also to stop jiggling when the scale bar is on the right hand of the screen
// This will most likely be a constant, as we take a max using a "hint" for
// the initial value
if (self.shouldLayoutBars) {
[self updateLabels];
}
CGFloat halfLabelWidth = ceil(self.lastLabelWidth/2);
self.size = CGSizeMake(totalBarWidth + halfLabelWidth, 16);
[self setNeedsLayout];
[super updateConstraints]; // This calls intrinsicContentSize
}
- (void)updateVisibility {
BOOL metric = [self usesMetricSystem];
NSUInteger count = metric
? sizeof(MLNMetricTable) / sizeof(MLNMetricTable[0])
: sizeof(MLNImperialTable) / sizeof(MLNImperialTable[0]);
CLLocationDistance maximumDistance = [self maximumWidth] * [self unitsPerPoint];
CLLocationDistance allowedDistance = metric
? MLNMetricTable[count-1].distance
: MLNImperialTable[count-1].distance;
CGFloat alpha = maximumDistance > allowedDistance ? .0f : 1.0f;
if (self.alpha != alpha) {
[UIView animateWithDuration:.2f delay:0 options:UIViewAnimationOptionBeginFromCurrentState animations:^{
self.alpha = alpha;
} completion:nil];
}
}
- (void)setRow:(MLNRow)row {
if (_row.distance == row.distance) {
return;
}
self.shouldLayoutBars = YES;
_row = row;
}
// MARK: - Views
- (NSArray<UIView *> *)bars {
if (!_bars) {
NSMutableArray *bars = [NSMutableArray array];
for (NSUInteger i = 0; i < self.row.numberOfBars; i++) {
UIView *bar = [[UIView alloc] init];
[bars addObject:bar];
[self.containerView addSubview:bar];
}
_bars = bars;
}
return _bars;
}
// MARK: - Labels
- (void)addZeroLabel {
NSDecimalNumber *zeroNumber = [NSDecimalNumber decimalNumberWithString:@"0"];
NSNumberFormatter *formatter = [[NSNumberFormatter alloc] init];
NSString *text = [formatter stringFromNumber:zeroNumber];
UIImage* image = [self imageForLabelText:text];
[self.labelImageCache setObject:image forKey:@(0)];
}
- (UIImage*)imageForLabelText:(NSString*)text {
self.prototypeLabel.text = text;
[self.prototypeLabel setNeedsDisplay];
[self.prototypeLabel sizeToFit];
// Now render
UIGraphicsBeginImageContextWithOptions(self.prototypeLabel.bounds.size, NO, 0.0);
[self.prototypeLabel.layer renderInContext: UIGraphicsGetCurrentContext()];
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return image;
}
- (UIImage*)cachedLabelImageForDistance:(CLLocationDistance)barDistance {
// Make a slightly nicer key, rather than something that's a double.
NSUInteger floorDist = (NSUInteger)(barDistance*100);
NSNumber *key = @(floorDist);
UIImage *cachedImage = [self.labelImageCache objectForKey:key];
if (cachedImage) {
return cachedImage;
}
// Calc it
NSString *text = [self.formatter stringFromDistance:barDistance];
UIImage *image = [self imageForLabelText:text];
[self.labelImageCache setObject:image forKey:key];
return image;
}
- (void)updateLabels {
NSEnumerator<UIView*> *viewEnumerator = [self.labelViews objectEnumerator];
NSUInteger i = 0;
CLLocationDistance multiplier = (self.row.distance / self.row.numberOfBars);
if (![self usesMetricSystem]) {
multiplier /= MLNFeetPerMeter;
}
for (; i <= self.row.numberOfBars; i++) {
UIView *labelView = [viewEnumerator nextObject];
labelView.hidden = NO;
CLLocationDistance barDistance = multiplier * i;
UIImage *image = [self cachedLabelImageForDistance:barDistance];
self.lastLabelWidth = MAX(self.lastLabelWidth, image.size.width);
labelView.layer.contents = (id)image.CGImage;
labelView.layer.contentsScale = image.scale;
}
// Hide the rest.
for (; i < self.labelViews.count; i++) {
UIView *labelView = [viewEnumerator nextObject];
labelView.hidden = YES;
}
}
// MARK: - Layout
- (void)layoutSubviews {
[super layoutSubviews];
if (!self.recalculateSize) {
return;
}
self.recalculateSize = NO;
// If size is 0, then we keep the existing layout (which will fade out)
if (self.size.width <= 0.0) {
return;
}
CGFloat totalBarWidth = self.actualWidth;
if (totalBarWidth <= 0.0) {
return;
}
if (self.shouldLayoutBars) {
self.shouldLayoutBars = NO;
[_bars makeObjectsPerformSelector:@selector(removeFromSuperview)];
_bars = nil;
}
// Re-layout the component bars and labels of the scale bar
CGFloat intrinsicContentHeight = self.intrinsicContentSize.height;
CGFloat barWidth = totalBarWidth/self.bars.count;
BOOL RTL = [self usesRightToLeftLayout];
CGFloat halfLabelWidth = ceil(self.lastLabelWidth/2);
CGFloat barOffset = RTL ? halfLabelWidth : 0.0;
self.containerView.frame = CGRectMake(barOffset,
intrinsicContentHeight - MLNBarHeight,
totalBarWidth,
MLNBarHeight);
[self layoutBarsWithWidth:barWidth];
CGFloat yPosition = round(0.5 * ( intrinsicContentHeight - MLNBarHeight));
CGFloat barDelta = RTL ? -barWidth : barWidth;
[self layoutLabelsWithOffset:barOffset delta:barDelta yPosition:yPosition];
}
- (void)layoutBarsWithWidth:(CGFloat)barWidth {
NSUInteger i = 0;
for (UIView *bar in self.bars) {
CGFloat xPosition = barWidth * i;
bar.backgroundColor = (i % 2 == 0) ? self.primaryColor : self.secondaryColor;
bar.frame = CGRectMake(xPosition, 0, barWidth, MLNBarHeight);
i++;
}
}
- (void)layoutLabelsWithOffset:(CGFloat)barOffset delta:(CGFloat)barDelta yPosition:(CGFloat)yPosition {
#if !defined(NS_BLOCK_ASSERTIONS)
NSUInteger countOfVisibleLabels = 0;
for (UIView *view in self.labelViews) {
if (!view.isHidden) {
countOfVisibleLabels++;
}
}
NSAssert(self.bars.count == countOfVisibleLabels - 1, @"");
#endif
CGFloat xPosition = barOffset;
if (barDelta < 0) {
xPosition -= (barDelta*self.bars.count);
}
for (UIView *label in self.labelViews) {
// Label frames have 0 size - though the layer contents use "center" and do
// not clip to bounds. This way we don't need to worry about positioning the
// label. (Though you won't see the label in the view debugger)
label.frame = CGRectMake(xPosition, yPosition, 0.0, 0.0);
xPosition += barDelta;
}
}
@end