Files
MapLibreParticleLab/platform/ios/src/MLNScaleBar.mm

615 lines
19 KiB
Plaintext

#import "MLNScaleBar.h"
#import "Mapbox.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, 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