3
\$\begingroup\$

In Swift I have a string where a dash needs to be inserted after every 3 characters, if there are 4 characters or 2 characters at the end of the string they should be shown as "-xx-xx" or "-xx" respectively.

E.g "203940399345" to "203-940-399-345"
    "2039403993454" to "203-940-399-34-54"
    "20394039934546" to "203-940-399-345-46" 
    "2039403993454699409399" to "203-940-399-345-469-940-93-99"

I have the following solution:

func format(_ unformatted:String) -> String {

  var formatted = ""
  let count = unformatted.characters.count

  unformatted.characters.enumerated().forEach {
    if $0.offset % 3 == 0 && $0.offset != 0 && $0.offset != count - 1 || $0.offset == count - 2 && count % 3 != 0 {
      formatted += "-" + String($0.element)
      return
    }
    formatted += String($0.element)
    return
  }
  return formatted
}

It will add a dash before each third char unless it is the first or last char or it is two from the end but only if the char count is divisible by 3 then it adds a char to the formatted string as normal.

Is there a more optimal way to achieve this formatting? What approach should I be taking with this kind of problem?

\$\endgroup\$
1
  • \$\begingroup\$ it seems you should use regex \$\endgroup\$
    – Vyacheslav
    Apr 11, 2017 at 11:13

3 Answers 3

2
\$\begingroup\$

The recursive approach is likely the most maintainable and flexible for this. It can be made fully configurable and can become an extension of the String type.

For example:

extension String
{
   func group(by groupSize:Int=3, separator:String="-") -> String
   {
      if characters.count <= groupSize   { return self }

      let splitSize  = min(max(2,characters.count-2) , groupSize)
      let splitIndex = index(startIndex, offsetBy:splitSize)

      return substring(to:splitIndex) 
           + separator 
           + substring(from:splitIndex).group(by:groupSize, separator:separator)
   }
}

string.group(by:3)
\$\endgroup\$
3
\$\begingroup\$

Your code is working fine. I don't think you can optimize your code but you can use reduce and extend String as follow:

extension String {
    var customFormatted: String {
        let count = characters.count
        return characters.enumerated().reduce("") { $1.offset % 3 == 0 && $1.offset != 0 && $1.offset != count-1 || $1.offset == count-2 && count % 3 != 0 ? $0 + "-\($1.element)" : $0 + "\($1.element)" } 
    }
}

Another option you have is to use map and join the resulting array:

extension String {
    var customFormatted: String {
        let count = characters.count
        return characters.enumerated().map { $0.offset % 3 == 0 && $0.offset != 0 && $0.offset != count-1 || $0.offset == count-2 && count % 3 != 0 ? "-\($0.element)" : "\($0.element)" }.joined()
    }

}

"2039403993454".customFormatted    // "203-940-399-34-54"
\$\endgroup\$
3
\$\begingroup\$

There is one small bug in your code, a two-character string is formatted with an initial dash:

print(format("12")) // -12

There are various ways to fix that, e.g. by replacing the condition

$0.offset == count - 2 && count % 3 != 0

with

$0.offset == count - 2 && $0.offset % 3 == 2

Now some suggestions to simplify the code and make it more readable. Instead of

unformatted.characters.enumerated().forEach {
     // ... use `$0.offset` and `$0.element` ...
}

I would use for ... in, so that we can give the loop variables names:

for (offset, char) in unformatted.characters.enumerated() {
     // ... use `offset` and `char` ...
}

There is no need to convert $0.element to a String because you can append a character directly:

formatted.append($0.element)

There is also no need to call return at the end of the iteration block.

The condition

if $0.offset % 3 == 0 && $0.offset != 0 && $0.offset != count - 1 || $0.offset == count - 2 && $0.offset % 2 == 2

is quite complex, I would split it into two conditions with if/else if.

Putting it all together, the function would then look like this:

func format(_ unformatted: String) -> String {

    var formatted = ""
    let count = unformatted.characters.count

    for (offset, char) in unformatted.characters.enumerated() {
        if offset > 0 && offset % 3 == 0 && offset != count - 1 {
            formatted.append("-")
        } else if offset % 3 == 2 && offset == count - 2 {
            formatted.append("-")
        }
        formatted.append(char)
    }

    return formatted
}

The condition whether to insert the separator at an offset is quite complex and it is easy to make an error. A completely different approach would be a recursive implementation, which is (almost) self-explaining:

func format(_ str: String) -> String {

    switch str.characters.count {
    case 0...3:  // No separators for strings up to length 3.
        return str
    case 4: // "abcd" -> "ab-cd"
        let idx = str.index(str.startIndex, offsetBy: 2)
        return str.substring(to: idx) + "-" + str.substring(from: idx)
    default: // At least 5 characters. Separate the first three and recurse:
        let idx = str.index(str.startIndex, offsetBy: 3)
        return str.substring(to: idx) + "-" + format(str.substring(from: idx))
    }

}

More suggestions:

  • Choose a different name instead of format() which indicates the purpose of the formatting.
  • Make the separator character a parameter of the function.
\$\endgroup\$
1
  • 1
    \$\begingroup\$ very good solutions. most i like the recursive solution because it becomes simple and self explaining code \$\endgroup\$
    – muescha
    Apr 11, 2017 at 23:57

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.