20

我正在实现一个“阅读更多”功能,就像 Apple 的 AppStore 中的功能一样。但是,我使用的是 multiline UILabel。看看 Apple 的 AppStore,他们如何减少最后一条可见行的宽度以适应“更多”文本并仍然截断尾部(见图)?

来自 AppStore 的 iBooks 示例图像

4

6 回答 6

13

这似乎可行,至少在我所做的有限数量的测试中是这样。有两种公共方法。如果您有多个标签都具有相同的行数,则可以使用较短的标签 - 只需更改顶部的 kNumberOfLines 以匹配您想要的。如果您需要传递不同标签的行数,请使用更长的方法。请务必将您在 IB 中制作的标签的类别更改为 RDLabel。使用这些方法代替 setText:。如果需要,这些方法会将标签的高度扩展为 kNumberOfLines,如果仍然被截断,则会扩展它以适应触摸时的整个字符串。目前,您可以触摸标签中的任何位置。改变它应该不会太难,所以只有在 ...Mer 附近接触才会导致扩张。

#import "RDLabel.h"
#define kNumberOfLines 2
#define ellipsis @"...Mer ▾ "

@implementation RDLabel {
    NSString *string;
}

#pragma Public Methods

- (void)setTruncatingText:(NSString *) txt {
    [self setTruncatingText:txt forNumberOfLines:kNumberOfLines];
}

- (void)setTruncatingText:(NSString *) txt forNumberOfLines:(int) lines{
    string = txt;
    self.numberOfLines = 0;
    NSMutableString *truncatedString = [txt mutableCopy];
    if ([self numberOfLinesNeeded:truncatedString] > lines) {
        [truncatedString appendString:ellipsis];
        NSRange range = NSMakeRange(truncatedString.length - (ellipsis.length + 1), 1);
        while ([self numberOfLinesNeeded:truncatedString] > lines) {
            [truncatedString deleteCharactersInRange:range];
            range.location--;
        }
        [truncatedString deleteCharactersInRange:range];  //need to delete one more to make it fit
        CGRect labelFrame = self.frame;
        labelFrame.size.height = [@"A" sizeWithFont:self.font].height * lines;
        self.frame = labelFrame;
        self.text = truncatedString;
        self.userInteractionEnabled = YES;
        UITapGestureRecognizer *tapper = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(expand:)];
        [self addGestureRecognizer:tapper];
    }else{
        CGRect labelFrame = self.frame;
        labelFrame.size.height = [@"A" sizeWithFont:self.font].height * lines;
        self.frame = labelFrame;
        self.text = txt;
    }
}

#pragma Private Methods

-(int)numberOfLinesNeeded:(NSString *) s {
    float oneLineHeight = [@"A" sizeWithFont:self.font].height;
    float totalHeight = [s sizeWithFont:self.font constrainedToSize:CGSizeMake(self.bounds.size.width, CGFLOAT_MAX) lineBreakMode:NSLineBreakByWordWrapping].height;
    return nearbyint(totalHeight/oneLineHeight);
}

-(void)expand:(UITapGestureRecognizer *) tapper {
    int linesNeeded = [self numberOfLinesNeeded:string];
    CGRect labelFrame = self.frame;
    labelFrame.size.height = [@"A" sizeWithFont:self.font].height * linesNeeded;
    self.frame = labelFrame;
    self.text = string;
}
于 2013-02-27T17:31:02.243 回答
6

由于这篇文章是从 2013 年开始的,我想给我的 Swift 实现来自@rdelmar 的非常好的解决方案。

考虑到我们正在使用 UILabel 的子类:

private let kNumberOfLines = 2
private let ellipsis = " MORE"

private var originalString: String! // Store the original text in the init

private func getTruncatingText() -> String {
    var truncatedString = originalString.mutableCopy() as! String

    if numberOfLinesNeeded(truncatedString) > kNumberOfLines {
        truncatedString += ellipsis

        var range = Range<String.Index>(
            start: truncatedString.endIndex.advancedBy(-(ellipsis.characters.count + 1)),
            end: truncatedString.endIndex.advancedBy(-ellipsis.characters.count)
        )

        while numberOfLinesNeeded(truncatedString) > kNumberOfLines {
            truncatedString.removeRange(range)

            range.startIndex = range.startIndex.advancedBy(-1)
            range.endIndex = range.endIndex.advancedBy(-1)
        }
    }

    return truncatedString
}

private func getHeightForString(str: String) -> CGFloat {
    return str.boundingRectWithSize(
        CGSizeMake(self.bounds.size.width, CGFloat.max),
        options: [.UsesLineFragmentOrigin, .UsesFontLeading],
        attributes: [NSFontAttributeName: font],
        context: nil).height
}

private func numberOfLinesNeeded(s: String) -> Int {
    let oneLineHeight = "A".sizeWithAttributes([NSFontAttributeName: font]).height
    let totalHeight = getHeightForString(s)
    return Int(totalHeight / oneLineHeight)
}

func expend() {
    var labelFrame = self.frame
    labelFrame.size.height = getHeightForString(originalString)
    self.frame = labelFrame
    self.text = originalString
}

func collapse() {
    let truncatedText = getTruncatingText()
    var labelFrame = self.frame
    labelFrame.size.height = getHeightForString(truncatedText)
    self.frame = labelFrame
    self.text = truncatedText
}

与旧的解决方案不同,这对任何类型的文本属性(如 NSParagraphStyleAttributeName)都适用。

请随时批评和评论。再次感谢@rdelmar。

于 2015-12-05T04:45:42.970 回答
4

有多种方法可以做到这一点,最优雅的是专门使用 CoreText,因为您可以完全控制如何显示文本。

这是一个混合选项,我们使用 CoreText 重新创建标签,确定它的结束位置,然后我们在正确的位置剪切标签文本字符串。

NSMutableAttributedString *atrStr = [[NSAttributedString alloc] initWithString:label.text];
NSNumber *kern = [NSNumber numberWithFloat:0];
NSRange full = NSMakeRange(0, [atrStr string].length);
[atrStr addAttribute:(id)kCTKernAttributeName value:kern range:full];

CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)atrStr);  

CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, label.frame);
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);

CFArrayRef lines = CTFrameGetLines(frame);
CTLineRef line = (CTLineRef)CFArrayGetValueAtIndex(lines, label.numberOfLines-1);
CFRange r = CTLineGetStringRange(line);

这为您提供了标签文本最后一行的范围。从那里,将它切开并将省略号放在您想要的位置是微不足道的。

第一部分创建一个属性字符串,它具有复制 UILabel 行为所需的属性(可能不是 100%,但应该足够接近)。然后我们创建一个framesetter和frame,并获取frame的所有行,从中我们提取标签的最后一个预期行的范围。

这显然是一种 hack,正如我所说,如果您想完全控制文本的外观,最好使用该标签的纯 CoreText 实现。

于 2013-02-27T14:03:16.583 回答
3

我刚刚在 Swift 4 中编写了一个 UILabel 扩展,使用二进制搜索来加速子字符串计算

它最初是基于@paul-slm 的解决方案,但已经大相径庭

extension UILabel {

func getTruncatingText(originalString: String, newEllipsis: String, maxLines: Int?) -> String {

    let maxLines = maxLines ?? self.numberOfLines

    guard maxLines > 0 else {
        return originalString
    }

    guard self.numberOfLinesNeeded(forString: originalString) > maxLines else {
        return originalString
    }

    var truncatedString = originalString

    var low = originalString.startIndex
    var high = originalString.endIndex
    // binary search substring
    while low != high {
        let mid = originalString.index(low, offsetBy: originalString.distance(from: low, to: high)/2)
        truncatedString = String(originalString[..<mid])
        if self.numberOfLinesNeeded(forString: truncatedString + newEllipsis) <= maxLines {
            low = originalString.index(after: mid)
        } else {
            high = mid
        }
    }

    // substring further to try and truncate at the end of a word
    var tempString = truncatedString
    var prevLastChar = "a"
    for _ in 0..<15 {
        if let lastChar = tempString.last {
            if (prevLastChar == " " && String(lastChar) != "") || prevLastChar == "." {
                truncatedString = tempString
                break
            }
            else {
                prevLastChar = String(lastChar)
                tempString = String(tempString.dropLast())
            }
        }
        else {
            break
        }
    }

    return truncatedString + newEllipsis
}

private func numberOfLinesNeeded(forString string: String) -> Int {
    let oneLineHeight = "A".size(withAttributes: [NSAttributedStringKey.font: font]).height
    let totalHeight = self.getHeight(forString: string)
    let needed = Int(totalHeight / oneLineHeight)
    return needed
}

private func getHeight(forString string: String) -> CGFloat {
    return string.boundingRect(
        with: CGSize(width: self.bounds.size.width, height: CGFloat.greatestFiniteMagnitude),
        options: [.usesLineFragmentOrigin, .usesFontLeading],
        attributes: [NSAttributedStringKey.font: font],
        context: nil).height
}
}
于 2018-06-14T04:29:56.097 回答
1

ResponsiveLabel是 UILabel 的子类,它允许添加响应触摸的自定义截断标记。

于 2015-05-29T11:44:29.503 回答
1

@paul-slm 上面的答案是我最终使用的答案,但是我发现将一个可能很长的字符串的最后一个字符逐个剥离,直到标签符合所需的行数,这是一个非常密集的过程。相反,一次将一个字符从原始字符串的开头复制到一个空白字符串会更有意义,直到满足所需的行数。您还应该考虑不要一次踩一个字符,而是一次踩多个字符,以便更快地到达“最佳位置”。我替换func getTruncatingText() -> String为以下内容:

private func getTruncatingText() -> String? {
    guard let originalString = originalString else { return nil }

    if numberOfLinesNeeded(originalString) > collapsedNumberOfLines {
        var truncatedString = ""
        var toyString = originalString
        while numberOfLinesNeeded(truncatedString + ellipsis) != (collapsedNumberOfLines + 1) {
            let toAdd = toyString.startIndex..<toyString.index(toyString.startIndex, offsetBy: 5)
            let toAddString = toyString[toAdd]
            toyString.removeSubrange(toAdd)
            truncatedString.append(String(toAddString))
        }

        while numberOfLinesNeeded(truncatedString + ellipsis) > collapsedNumberOfLines {
            truncatedString.removeSubrange(truncatedString.index(truncatedString.endIndex, offsetBy: -1)..<truncatedString.endIndex)
        }

        truncatedString += ellipsis
        return truncatedString
    } else {
        return originalString
    }
}
于 2018-07-10T23:02:04.823 回答