iOS、Android 实现电话号码格式化操作

控件简介

最近研究如何根据配置好的字符串去格式化电话号码等信息。由于项目要求,我们格式化的字符串是客户在网页端自定义的。因此要求我们这边需要针对不同的配置进行动态的格式化字符串。例如:需要格式化为 +86 XXX-XXXX-XXXX, X 替换为 number 去格式化字符串。网络上搜索了一下,基本上没有成熟的代码可用。索性,自己实现一套。

思路:

在 TextField 的代理方法中,func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool。我们可以在这个方法中拦截掉输入请求,自己管理字符串的键入与显示。这样就可以实现任意功能。在方法中,我的思路是:

  1. 根据当前未做变化前的显示字符串中计算出未格式化的数字字符串
  2. 根据当前显示字符串中的 Range 计算在数字字符串中的 Range
  3. 操作数字字符串中的 Range,并获取在数字字符串上的光标停留位置
  4. 生成显示的字符串,根据数字字符串的光标位置计算显示字符串的光标位置。

iOS 代码部分

代码部分:

  1. 根据原来的字符串生成未格式化的数字字符串

    private func getOriString() -> String {
        var str = ""
        for index in 0..<phoneFormate.count {
            let start = phoneFormate.index(phoneFormate.startIndex, offsetBy:index)
            let end = phoneFormate.index(start, offsetBy:1)
            let x = String(phoneFormate[start..<end])
            if textField.text!.count > index {
                let y = String(textField.text![start..<end])
                if String(x) == "X" {
                    str += y
                }
            }
        }
        return str
    }
    
  2. 根据字符串操作的 Range 计算在未格式化字符串上操作的 Range。

     private func getOriRange(_ range: NSRange) -> NSRange {
         var myLocation = 0
         var richLocation = false
         var currentLength: Int?
         var oriLength: Int?
         for index in 0..<phoneFormate.count {
             let start = phoneFormate.index(phoneFormate.startIndex, offsetBy:index)
             let end = phoneFormate.index(start, offsetBy:1)
             let x = String(phoneFormate[start..<end])
             
             if range.length == currentLength {
                 return NSRange(location: myLocation, length: oriLength ?? 0)
             }
             if range.location == index {
                 richLocation = true
                 currentLength = 0
                 oriLength = 0
                 if range.length == 0 {
                     return NSRange(location: myLocation, length: 0)
                 }
             }
             if String(x) == "X" {
                 if !richLocation {
                     myLocation += 1
                 }
                 if oriLength != nil {
                     oriLength = oriLength! + 1
                 }
             }
             
             if currentLength != nil {
                 currentLength = currentLength! + 1
             }
         }
         return NSRange(location: myLocation, length: oriLength ?? 0)
     }
    
  3. 操作未格式化的字符串,并返回最终光标在未格式化字符串上的位置

    private func operationOriString(oriStr: String, oriRange: NSRange, string: String) -> Int {
        var selectedIndex = 0
        if string.count == 0 {
            origineString = (oriStr as NSString).replacingCharacters(in: oriRange, with: string)
            selectedIndex = oriRange.location
        } else {
            let start = oriStr.index(oriStr.startIndex, offsetBy:oriRange.location + oriRange.length)
            let firstStr = String(oriStr[oriStr.startIndex..<start])
            let lastStr = String(oriStr[start..<oriStr.endIndex])
            
            selectedIndex = firstStr.count + 1
            origineString = firstStr + string + lastStr
        }
        if origineString.count > formateInfo.numberCount {
            origineString = String(origineString[origineString.startIndex..<origineString.index(origineString.startIndex, offsetBy:formateInfo.numberCount)])
        }
        return selectedIndex
    }
    
  4. 生成格式化后的字符串,并根据原始字符串的光标位置生成格式化后字符串的光标

    private func generateStr(_ oriIndex: Int) -> (String, Int) {
        var showStr = ""
        var index = 0
        var selectedIndex = 0
        for f in 0..<phoneFormate.count {
            let start = phoneFormate.index(phoneFormate.startIndex, offsetBy:f)
            let end = phoneFormate.index(start, offsetBy:1)
            let x = String(phoneFormate[start..<end])
            if oriIndex == index {
                selectedIndex = f
            }
            if String(x) == "X" {
                if origineString.count > index {
                    let start = origineString.index(origineString.startIndex, offsetBy:index)
                    if origineString.count > 1 {
                        let end = origineString.index(start, offsetBy:1)
                        showStr += String(origineString[start..<end])
                    } else {
                        showStr += origineString
                    }
                    index += 1
                } else {
                    break
                }
            } else {
                if origineString.count > index {
                    showStr += x
                } else {
                    break
                }
            }
        }
        if oriIndex == index && selectedIndex == 0 {
            selectedIndex = phoneFormate.count
        }
        return (showStr, selectedIndex)
    }
    
  5. 总的调用函数。

    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        // 没有格式化,当做普通的 TextField 使用
        if self.phoneFormate == "" && self.regularFormate == "" {
            return true
        }
        
        // 字符串超长,并且当前操作是输入操作,光标放置到最后。
        if origineString.count >= formateInfo.numberCount && string.count > 0 {
            textField.selectedTextRange = textField.textRange(from: textField.endOfDocument, to: textField.endOfDocument)
            return false
        }
        // 手动管理修改字符串。
        self.modifyValueIn(range: range, string: string)
        // 发送事件,外部可以获取实时字符串
        sendActions(for: .editingChanged)
        
        return false
    }
    
    public func modifyValueIn(range: NSRange, string: String)  {
        // 1. 提取原始字符串
        let oriStr = self.getOriString()
        
        // 2. 根据 range 生成原始字符串 range
        let oriRange: NSRange = self.getOriRange(range)
        
        // 3. 操作原始字符串, 并获取在显示字符串中字符光标的位置
        let selectedIndex = self.operationOriString(oriStr: oriStr, oriRange: oriRange, string: string)
        
        // 4. 生成显示字符串
        let showStr = self.generateStr(selectedIndex)
        
        // 5. 计算最终的光标移位
        textField.insertText(showStr.0)
        textField.text = showStr.0
        if string.count == 0 {
            textField.selectedTextRange = textField.textRange(from: textField.endOfDocument, to: textField.endOfDocument)
        } else {
            let newPosition = textField.position(from: textField.beginningOfDocument, offset: showStr.1)
            textField.selectedTextRange = textField.textRange(from: newPosition!, to: newPosition!)
        }
    }
    

Android 代码部分

Android 思路与 iOS 版本的思路相同。不过不同之处在于,iOS 使用的是代理方法中的 func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool。拦截掉输入请求。而 Android 中并没有对应的方法。但是 Android 有着 TextWatcher 与 iOS 的代理方法类似。经过一些测试,我对 TextWatcher 的实现做了一部分修改,可以实现与 iOS 类似的功能。

  1. 针对 TextWatcher 做出计算类的修改, 在 afterTextChanged 方法中实现计算方法就可以了。

    private class PhoneFormateInfo(var numberCount: Int = 0)
    private class NSRange(var location: Int, var length: Int)
    private class GenerateStringStatus(var showStr: String, var selectedIndex: Int)
    
    private var beginStr: String? = null
    private var replacementString: String = ""
    private var range: NSRange? = null
    
    private fun reset() {
        range = null
        beginStr = null
        replacementString = ""
    }
    
    override fun afterTextChanged(s: Editable?) {
        if (phoneFormate == "" && regularFormate == "") {
            reset()
            return
        }
        if (origineString.length >= formateInfo.numberCount && replacementString!!.isNotEmpty()) {
            editView.removeTextChangedListener(this)
            editView.setText(beginStr!!)
            editView.setSelection(beginStr!!.length)
            textUpdateListener?.textDidChanged(beginStr!!)
            judgeRegular()
            editView.addTextChangedListener(this)
            reset()
            return
        }
    
        modifyValueIn(range!!, replacementString!!)
        reset()
    
        judgeRegular()
    }
    
    
    // 输入框的原内容字符串S,从索引位置start开始,有count个字符即将被替换,替换这个count个字符的新的字符个数为after。注意:S是变化之前的输入框内容。
    override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
        beginStr = s.toString()
        range = if (count > 0) {
            NSRange(start, count)
        } else {
            NSRange(start, 0)
        }
    }
    
    // 在变化时的新的字符串S里,从索引位置start开始,有count个字符,是替换了原来的before个字符的。注意:S是变化之后的输入框内容
    override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
        if (count > 0) {
            val string = s.toString().substring(start, start + count)
            for (index in 0 until string.length) {
                val charStr = string[index]
                val num = charStr.toInt()
                if (num in 48..57) {
                    replacementString += charStr.toString()
                }
            }
        }
    }
    
  2. 同样的思路,实现格式化字符串

    /// 生成原始字符串
    private fun getOriString(): String {
        var str = ""
        for (index in 0 until phoneFormate.length) {
            val x = phoneFormate[index].toString()
            if (beginStr!!.length > index) {
                val y = beginStr!![index].toString()
                if (x == "X") {
                    str += y
                }
            }
        }
        return str
    }
    
    // 根据字符串操作范围生成原始字符串操作范围
    private fun getOriRange(range: NSRange): NSRange {
        var myLocation = 0
        var richLocation = false
        var currentLength: Int? = null
        var oriLength: Int? = null
        for (index in 0 until phoneFormate.length) {
            val x = phoneFormate[index].toString()
    
            if (currentLength != null && range.length == currentLength) {
                return NSRange(myLocation, oriLength ?: 0)
            }
            if (range.location == index) {
                richLocation = true
                currentLength = 0
                oriLength = 0
                if (range.length == 0) {
                    return NSRange(myLocation, 0)
                }
            }
            if (x == "X") {
                if (!richLocation) {
                    myLocation += 1
                }
                if (oriLength != null) {
                    oriLength = oriLength!! + 1
                }
            }
    
            if (currentLength != null) {
                currentLength = currentLength!! + 1
            }
        }
        return NSRange(myLocation, oriLength ?: 0)
    }
    
    private fun generateStr(oriIndex: Int): GenerateStringStatus {
        var showStr = ""
        var index = 0
        var selectedIndex = 0
        for (f in 0 until phoneFormate.length) {
            val x = phoneFormate[f].toString()
    
            if (oriIndex == index) {
                selectedIndex = f
            }
            if (x == "X") {
                if (origineString.length > index) {
                    if (origineString.length > 1) {
                        showStr += origineString[index].toString()
                    } else {
                        showStr += origineString
                    }
                    index += 1
                } else {
                    break
                }
            } else {
                if (origineString.length > index) {
                    showStr += x
                } else {
                    break
                }
            }
        }
        if (oriIndex == index && selectedIndex == 0) {
            selectedIndex = phoneFormate.length
        }
        return GenerateStringStatus(showStr, selectedIndex)
    }
    
    private fun operationOriString(oriStr: String, oriRange: NSRange, string: String): Int {
        var selectedIndex = 0
        if (string.isEmpty()) {
            origineString = oriStr.replaceRange(oriRange.location, oriRange.location + oriRange.length, string)
            selectedIndex = oriRange.location
        } else {
    
            val firstStr = oriStr.substring(0, oriRange.location + oriRange.length).toString()
            val lastStr = oriStr.substring( oriRange.location + oriRange.length, oriStr.length).toString()
    
            selectedIndex = firstStr.length + 1
            origineString = firstStr + string + lastStr
        }
        if (origineString.length > formateInfo.numberCount) {
            origineString = origineString.substring(0, formateInfo.numberCount)
        }
        return selectedIndex
    }
    
    private fun modifyValueIn(range: NSRange, string: String)  {
        // 1. 提取原始字符串
        val oriStr = getOriString()
    
        // 2. 根据 range 生成原始字符串 range
        val oriRange: NSRange = getOriRange(range)
    
        // 3. 操作原始字符串, 并获取在显示字符串中字符光标的位置
        val selectedIndex = operationOriString(oriStr, oriRange, string)
    
        // 4. 生成显示字符串
        val showStr = generateStr(selectedIndex)
        editView.removeTextChangedListener(this)
    
        // 5. 计算最终的光标移位
        editView.setText(showStr.showStr)
        if (string.isEmpty()) {
            editView.setSelection(showStr.showStr.length)
        } else {
            editView.setSelection(showStr.selectedIndex)
        }
        textUpdateListener?.textDidChanged(showStr.showStr)
        editView.addTextChangedListener(this)
    }
    
  3. 由于 Android 版本在复制粘贴可能存在问题,因此需要禁止掉它的复制粘贴功能

    fun canPaste(): Boolean {
        return false
    }
    
    fun canCut(): Boolean {
        return false
    }
    
    fun canCopy(): Boolean {
        return false
    }
    
    fun canSelectAllText(): Boolean {
        return false
    }
    
    fun canSelectText(): Boolean {
        return false
    }
    
    fun textCanBeSelected(): Boolean {
        return false
    }
    
    override fun onTextContextMenuItem(id: Int): Boolean {
        return true
    }
    
    init {
        isLongClickable = false
        setTextIsSelectable(false)
    
        inputType = InputType.TYPE_CLASS_NUMBER
        filters = listOf(InputFilter { _, _, _, _, _, _ -> null }).toTypedArray()
    
        customSelectionActionModeCallback = object: ActionMode.Callback {
            override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
                return false
            }
    
            override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
                return false
            }
    
            override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
                return false
            }
    
            override fun onDestroyActionMode(mode: ActionMode?) {
            }
        }
    }
    

至此,Android 功能也就完成了。

Android 注意点:在代码中设置 text 值的时候,TextWatcher 同样调用了。因此我在设置 text 的时候拿掉了 TextWatcher 代理,设置完成后加上了 TextWatcher 代理。目前来看还算 ok。

总结

这么写的好处在于可以自定义格式,并且代码通用。不需要一个格式写一套格式代码。

Android 是由于公司同事没有时间实现,因此我就看了一下 Android 的 TextEdit 的原理实现了一下。可能存在问题,敬请指教。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容