调整大小后,UITextView 的内容因额外的 space 而错位

UITextView's content getting misplaced with extra space after resizing

问题的背景和描述

我制作了一个垂直文本视图以用于蒙古语。它是一个自定义文本视图,由三层视图组成:一个子视图 UITextView、一个容纳 UITextView 的容器视图(旋转 90 度并翻转)和父视图。 (有关更多背景信息,请参阅 here and 。)

视图根据基础文本视图的内容大小增加其大小,只要它在最小和最大大小之间。然而,在过去的几天里,我一直在努力修复一个错误,其中添加了额外的 space 并且内容向左移动(这将在底层文本视图的坐标上)。这可以在下图中观察到。黄色视图是自定义文本视图(在下面的视图控制器代码中称为 inputWindow。)

在我点击 Enter 几次以增加内容视图的大小后,添加了一个额外的 space。尝试滚动视图什么都不做。 (在宽度达到最大值并且内容大小大于框架大小后,滚动才起作用。)就好像内容在滚动的中间,当它被冻结在适当的位置之前,它可以被放置在正确的位置。如果我插入另一个字符(如 space),那么内容视图会自行更新到正确的位置。

问题

我需要更改什么?或者我如何手动强制底层 UITextView 在正确的位置显示其内容视图?

代码

我已尝试删除所有无关代码,只保留视图控制器和自定义垂直 TextView 的相关部分。如果还有什么我应该包括的,请告诉我。

视图控制器

视图控制器在内容视图大小更改时更新自定义文本视图的大小约束。

import UIKit
class TempViewController: UIViewController, KeyboardDelegate {

    let minimumInputWindowSize = CGSize(width: 80, height: 150)
    let inputWindowSizeIncrement: CGFloat = 50

    // MARK:- Outlets
    @IBOutlet weak var inputWindow: UIVerticalTextView!
    @IBOutlet weak var topContainerView: UIView!
    @IBOutlet weak var keyboardContainer: KeyboardController!
    @IBOutlet weak var inputWindowHeightConstraint: NSLayoutConstraint!
    @IBOutlet weak var inputWindowWidthConstraint: NSLayoutConstraint!


    override func viewDidLoad() {
        super.viewDidLoad()

        // get rid of space at beginning of textview
        self.automaticallyAdjustsScrollViewInsets = false

        // setup keyboard
        keyboardContainer.delegate = self
        inputWindow.underlyingTextView.inputView = UIView()
        inputWindow.underlyingTextView.becomeFirstResponder()
    }

    // KeyboardDelegate protocol
    func keyWasTapped(character: String) {
        inputWindow.insertMongolText(character) // code omitted for brevity
        increaseInputWindowSizeIfNeeded()
    }
    func keyBackspace() {
        inputWindow.deleteBackward() // code omitted for brevity
        decreaseInputWindowSizeIfNeeded()
    }

    private func increaseInputWindowSizeIfNeeded() {

        if inputWindow.frame.size == topContainerView.frame.size {
            return
        }

        // width
        if inputWindow.contentSize.width > inputWindow.frame.width &&
            inputWindow.frame.width < topContainerView.frame.size.width {
            if inputWindow.contentSize.width > topContainerView.frame.size.width {
                //inputWindow.scrollEnabled = true
                inputWindowWidthConstraint.constant = topContainerView.frame.size.width
            } else {
                self.inputWindowWidthConstraint.constant = self.inputWindow.contentSize.width
            }
        }

        // height
        if inputWindow.contentSize.width > inputWindow.contentSize.height {
            if inputWindow.frame.height < topContainerView.frame.height {
                if inputWindow.frame.height + inputWindowSizeIncrement < topContainerView.frame.height {
                    // increase height by increment unit
                    inputWindowHeightConstraint.constant = inputWindow.frame.height + inputWindowSizeIncrement
                } else {
                    inputWindowHeightConstraint.constant = topContainerView.frame.height
                }
            }
        }
    }

    private func decreaseInputWindowSizeIfNeeded() {

        if inputWindow.frame.size == minimumInputWindowSize {
            return
        }

        // width
        if inputWindow.contentSize.width < inputWindow.frame.width &&
            inputWindow.frame.width > minimumInputWindowSize.width {

            if inputWindow.contentSize.width < minimumInputWindowSize.width {
                inputWindowWidthConstraint.constant = minimumInputWindowSize.width
            } else {
                inputWindowWidthConstraint.constant = inputWindow.contentSize.width
            }
        }

        // height
        if (2 * inputWindow.contentSize.width) <= inputWindow.contentSize.height && inputWindow.contentSize.width < topContainerView.frame.width {
            // got too high, make it shorter
            if minimumInputWindowSize.height < inputWindow.contentSize.height - inputWindowSizeIncrement {
                inputWindowHeightConstraint.constant = inputWindow.contentSize.height - inputWindowSizeIncrement
            } else {
                // Bump down to min height
                inputWindowHeightConstraint.constant = minimumInputWindowSize.height
            }
        }
    }
}

自定义垂直文本视图

这个自定义视图基本上是 允许旋转和翻转以正确查看传统蒙古语。

import UIKit
@IBDesignable class UIVerticalTextView: UIView {

    var textView = UITextView()
    let rotationView = UIView()

    var underlyingTextView: UITextView {
        get {
            return textView
        }
        set {
            textView = newValue
        }
    }


    var contentSize: CGSize {
        get {
            // height and width are swapped because underlying view is rotated 90 degrees
            return CGSize(width: textView.contentSize.height, height: textView.contentSize.width)
        }
        set {
            textView.contentSize = CGSize(width: newValue.height, height: newValue.width)
        }
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override init(frame: CGRect){
        super.init(frame: frame)
        self.setup()
    }

    override func awakeFromNib() {
        super.awakeFromNib()
        self.setup()
    }

    func setup() {

        textView.backgroundColor = UIColor.yellowColor()
        self.textView.translatesAutoresizingMaskIntoConstraints = false
        self.addSubview(rotationView)
        rotationView.addSubview(textView)

        // add constraints to pin TextView to rotation view edges.
        let leadingConstraint = NSLayoutConstraint(item: self.textView, attribute: NSLayoutAttribute.Leading, relatedBy: NSLayoutRelation.Equal, toItem: rotationView, attribute: NSLayoutAttribute.Leading, multiplier: 1.0, constant: 0)
        let trailingConstraint = NSLayoutConstraint(item: self.textView, attribute: NSLayoutAttribute.Trailing, relatedBy: NSLayoutRelation.Equal, toItem: rotationView, attribute: NSLayoutAttribute.Trailing, multiplier: 1.0, constant: 0)
        let topConstraint = NSLayoutConstraint(item: self.textView, attribute: NSLayoutAttribute.Top, relatedBy: NSLayoutRelation.Equal, toItem: rotationView, attribute: NSLayoutAttribute.Top, multiplier: 1.0, constant: 0)
        let bottomConstraint = NSLayoutConstraint(item: self.textView, attribute: NSLayoutAttribute.Bottom, relatedBy: NSLayoutRelation.Equal, toItem: rotationView, attribute: NSLayoutAttribute.Bottom, multiplier: 1.0, constant: 0)
        rotationView.addConstraints([leadingConstraint, trailingConstraint, topConstraint, bottomConstraint])
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        rotationView.transform = CGAffineTransformIdentity
        rotationView.frame = CGRect(origin: CGPointZero, size: CGSize(width: self.bounds.height, height: self.bounds.width))
        rotationView.userInteractionEnabled = true
        rotationView.transform = translateRotateFlip()
    }

    func translateRotateFlip() -> CGAffineTransform {

        var transform = CGAffineTransformIdentity

        // translate to new center
        transform = CGAffineTransformTranslate(transform, (self.bounds.width / 2)-(self.bounds.height / 2), (self.bounds.height / 2)-(self.bounds.width / 2))
        // rotate counterclockwise around center
        transform = CGAffineTransformRotate(transform, CGFloat(-M_PI_2))
        // flip vertically
        transform = CGAffineTransformScale(transform, -1, 1)

        return transform
    }

}

我试过的

我尝试过的很多想法都来自How do I size a UITextView to its content? 具体来说,我尝试过:

设置框架而不是自动布局

在我做的自定义视图layoutSubviews()方法中

textView.frame = rotationView.bounds

而且我没有在 setup() 中添加约束。没有明显的效果。

allowsNonContiguousLayout

这也没有效果。 (建议here。)

textView.layoutManager.allowsNonContiguousLayout = false

setNeedsLayout

我在输入 Window 和基础文本视图上尝试了 setNeedsLayoutsetNeedsDisplay 的各种组合。

inputWindow.setNeedsLayout()
inputWindow.underlyingTextView.setNeedsLayout()

甚至在 dispatch_async 中,以便在下一个 运行 循环中得到 运行。

dispatch_async(dispatch_get_main_queue()) {
    self.inputWindow.setNeedsLayout()
}

sizeToFit

在更新宽度约束后在下一个 运行 循环中执行 sizeToFit 一开始看起来很有希望,但它仍然没有解决问题。有时内容会冻结,有时会滚动。它并不总是每次都冻结在同一个地方。

self.inputWindowWidthConstraint.constant = self.inputWindow.contentSize.width
dispatch_async(dispatch_get_main_queue()) {
    self.inputWindow.underlyingTextView.sizeToFit()
}

延迟

我一直在看 scheduling a delayed event,但这感觉像是一个 hack。

重复?

一个类似的问题是 UITextview gains an extra line when it should not。但是,它在 Objective-C 中,所以我不太清楚。也是6岁,没有答案。

This answer 还提到了 iPhone 6+ 上的额外 space(我上面的测试图像是 iPhone 6,而不是 6+)。但是,我想我尝试了该答案中的建议。也就是说,我做了

var _f = self.inputWindow.underlyingTextView.frame
_f.size.height = self.inputWindow.underlyingTextView.contentSize.height
self.inputWindow.underlyingTextView.frame = _f

没有明显影响。

更新:一个基本的可复制项目

为了让这个问题尽可能的重现,我做了一个独立的项目。它在 Github here 上可用。故事板布局如下所示:

黄色的UIViewclass是inputWindow,应该设置为UIVerticalTextView。浅蓝色视图是 topContainerView。下面的按钮取代了键盘。

添加显示的自动布局约束。输入 Window 的宽度限制为 80,高度限制为 150。

将出口和操作连接到下面的视图控制器代码。此视图控制器代码完全替换了我在上面的原始示例中使用的视图控制器代码。

视图控制器

import UIKit
class ViewController: UIViewController {

    let minimumInputWindowSize = CGSize(width: 80, height: 150)
    let inputWindowSizeIncrement: CGFloat = 50

    // MARK:- Outlets
    @IBOutlet weak var inputWindow: UIVerticalTextView!
    @IBOutlet weak var topContainerView: UIView!
    //@IBOutlet weak var keyboardContainer: KeyboardController!
    @IBOutlet weak var inputWindowHeightConstraint: NSLayoutConstraint!
    @IBOutlet weak var inputWindowWidthConstraint: NSLayoutConstraint!

    @IBAction func enterTextButtonTapped(sender: UIButton) {
        inputWindow.insertMongolText("a")
        increaseInputWindowSizeIfNeeded()
    }
    @IBAction func newLineButtonTapped(sender: UIButton) {
        inputWindow.insertMongolText("\n")
        increaseInputWindowSizeIfNeeded()
    }
    @IBAction func deleteBackwardsButtonTapped(sender: UIButton) {
        inputWindow.deleteBackward()
        decreaseInputWindowSizeIfNeeded()
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        // get rid of space at beginning of textview
        self.automaticallyAdjustsScrollViewInsets = false

        // hide system keyboard but show cursor
        inputWindow.underlyingTextView.inputView = UIView()
        inputWindow.underlyingTextView.becomeFirstResponder()
    }

    private func increaseInputWindowSizeIfNeeded() {

        if inputWindow.frame.size == topContainerView.frame.size {
            return
        }

        // width
        if inputWindow.contentSize.width > inputWindow.frame.width &&
            inputWindow.frame.width < topContainerView.frame.size.width {
            if inputWindow.contentSize.width > topContainerView.frame.size.width {
                //inputWindow.scrollEnabled = true
                inputWindowWidthConstraint.constant = topContainerView.frame.size.width
            } else {
                self.inputWindowWidthConstraint.constant = self.inputWindow.contentSize.width
            }
        }

        // height
        if inputWindow.contentSize.width > inputWindow.contentSize.height {
            if inputWindow.frame.height < topContainerView.frame.height {
                if inputWindow.frame.height + inputWindowSizeIncrement < topContainerView.frame.height {
                    // increase height by increment unit
                    inputWindowHeightConstraint.constant = inputWindow.frame.height + inputWindowSizeIncrement
                } else {
                    inputWindowHeightConstraint.constant = topContainerView.frame.height
                }
            }
        }
    }

    private func decreaseInputWindowSizeIfNeeded() {

        if inputWindow.frame.size == minimumInputWindowSize {
            return
        }

        // width
        if inputWindow.contentSize.width < inputWindow.frame.width &&
            inputWindow.frame.width > minimumInputWindowSize.width {

            if inputWindow.contentSize.width < minimumInputWindowSize.width {
                inputWindowWidthConstraint.constant = minimumInputWindowSize.width
            } else {
                inputWindowWidthConstraint.constant = inputWindow.contentSize.width
            }
        }

        // height
        if (2 * inputWindow.contentSize.width) <= inputWindow.contentSize.height && inputWindow.contentSize.width < topContainerView.frame.width {
            // got too high, make it shorter
            if minimumInputWindowSize.height < inputWindow.contentSize.height - inputWindowSizeIncrement {
                inputWindowHeightConstraint.constant = inputWindow.contentSize.height - inputWindowSizeIncrement
            } else {
                // Bump down to min height
                inputWindowHeightConstraint.constant = minimumInputWindowSize.height
            }
        }
    }
}

UIVerticalTextView

使用与原始示例中的 UIVerticalTextView 相同的代码,但增加了以下两种方法。

func insertMongolText(unicode: String) {
    textView.insertText(unicode)
}

func deleteBackward() {
    textView.deleteBackward()
}

测试

  1. 点击 "insert text" 几次。 (请注意,文本是向后的,因为实际的应用程序使用镜像字体来补偿翻转的文本视图。)
  2. 点击 "new line" 五次。
  3. 尝试滚动视图。

观察到内容错位,视图不会滚动。

我需要做什么来解决这个问题?

是否可以给我们一个示例项目(github)?

您能否通过对 UIVerticalTextView 文件的以下代码稍作更改来进行测试:

override func layoutSubviews() {
    super.layoutSubviews()

    rotationView.transform = CGAffineTransformIdentity
    rotationView.frame = CGRect(origin: CGPointZero, size: CGSize(width: self.bounds.height, height: self.bounds.width))
    rotationView.userInteractionEnabled = true
    rotationView.transform = translateRotateFlip()

    if self.textView.text.isEmpty == false {
        self.textView.scrollRangeToVisible(NSMakeRange(0, 1))
    }
}

我找到了一个可接受的解决方案。它涉及

  1. 取消自动布局(在自定义文本视图中)和
  2. 在更新前添加延迟。

在示例项目中,这给出了以下结果。

文本视图的内容将其位置更新到正确的位置。

没有自动布局

UIVerticalTextView class中我注释掉了自动布局约束行:

let leadingConstraint = NSLayoutConstraint(item: self.textView, attribute: NSLayoutAttribute.Leading, relatedBy: NSLayoutRelation.Equal, toItem: rotationView, attribute: NSLayoutAttribute.Leading, multiplier: 1.0, constant: 0)
let trailingConstraint = NSLayoutConstraint(item: self.textView, attribute: NSLayoutAttribute.Trailing, relatedBy: NSLayoutRelation.Equal, toItem: rotationView, attribute: NSLayoutAttribute.Trailing, multiplier: 1.0, constant: 0)
let topConstraint = NSLayoutConstraint(item: self.textView, attribute: NSLayoutAttribute.Top, relatedBy: NSLayoutRelation.Equal, toItem: rotationView, attribute: NSLayoutAttribute.Top, multiplier: 1.0, constant: 0)
let bottomConstraint = NSLayoutConstraint(item: self.textView, attribute: NSLayoutAttribute.Bottom, relatedBy: NSLayoutRelation.Equal, toItem: rotationView, attribute: NSLayoutAttribute.Bottom, multiplier: 1.0, constant: 0)
rotationView.addConstraints([leadingConstraint, trailingConstraint, topConstraint, bottomConstraint])

请注意,这些是自定义视图本身内的自动布局约束(用于将 textView 框架固定到 rotationView 边界),而不是情节提要中的自动布局约束。

因此,我在 layoutSubviews 中设置了 textView.frame = rotationView.bounds,而不是自动布局:

override func layoutSubviews() {
    super.layoutSubviews()

    // ...

    textView.frame = rotationView.bounds
}

延迟

宽度增加后,我在调用之前添加了 100 毫秒的延迟 setNeedsLayout

private func increaseInputWindowSizeIfNeeded() {

    // ...

    // width
    if inputWindow.contentSize.width > inputWindow.frame.width &&
        // ...
        } else {

            self.inputWindowWidthConstraint.constant = self.inputWindow.contentSize.width

            // ********** Added delay here *********
            let delay = 0.1
            let time = dispatch_time(DISPATCH_TIME_NOW, Int64(delay * Double(NSEC_PER_SEC)))
            dispatch_after(time, dispatch_get_main_queue()) {
                self.inputWindow.setNeedsLayout()
            }
            // *************************************

        }
    }

仍在寻找更好的解决方案

设置delay = 0.1在模拟器中有效,但如果我设置delay = 0.05则不起作用。因此,如果不在所有设备上进行测试,我无法知道我的延迟是否足够长。出于这个原因,我认为这个解决方案更像是一个黑客而不是一个真正的解决方案。

无论如何,我不能把赏金奖励给自己,所以如果有人能想出更好的解决方案,我会很高兴听到。