Issue #252

Read more https://medium.com/flawless-app-stories/how-to-make-auto-layout-more-convenient-in-ios-df3b42fed37f

Description

This is a script to remove Cartography, and use plain NSLayoutAnchor syntax. Use Constraint.on() from Sugar. It will change all .swift files recursively under provided folder.

Constraint.on(
    logoImageView.widthAnchor.constraint(equalToConstant: 74),
    view1.leftAnchor.constraint(equalTo: view2.leftAnchor, constant: 20),
)

Features

  • Parse recursively
  • Parse a single file
  • Parse each constrain block separately
  • Handle ==, >=, <=
  • Handle center, edges
  • Infer indentation
  • Handle relation with other item
  • Handle relation with constant
  • Handle multiplier
  • Auto import Sugar
  • Infer superView

Prepare

Install tool if needed

brew install yarn

How to use

yarn install
yarn start /Users/khoa/path/to/project

Code


const Glob = require('glob')
const Fs = require('fs')

function main() {
  const projectPath = getProjectPath()
  const files = getAllFiles(projectPath)

  files.forEach((file) => {
    handleFile(file)
  })
}

/// The 1st argument is node, 2nd is index.js, 3rd is path
function getProjectPath() {
  return process.argv[2]
}

/// Only select `swift` file
function getAllFiles(projectPath) {
  if (projectPath.endsWith('.swift')) {
    return [projectPath]
  } else {
    const files = Glob.sync(`${projectPath}/**/*.swift`)
    return files
  }
}

/// Read file, replace by matches, and write again
function handleFile(file) {
  console.log(file)

  Fs.readFile(file, 'utf8', (error, data) => {
    // non greedy `*?`
    const pattern = '(.)*constrain\\(.+\\) {\\s(\\s|.)*?\\n(\\s)*}'
    const regex = new RegExp(pattern)
    
    let matches = data.match(regex)

    // RegEx only return the 1st match per execution, let's do a recursion
    while (matches != null) {
      const match = matches[0]

      const indentationLength = findIndentationLength(match)
      const rawTransforms = handleMatch(match)
      const transforms = handleSuperview(rawTransforms, match)
      const statements = handleTransforms(transforms, indentationLength)
      const string = handleStatements(statements, indentationLength)
      data = data.replace(match, string)

      // Examine again
      matches = data.match(regex)
    }

    // Handle import
    data = handleImport(data)


    Fs.writeFile(file, data, 'utf8')
  })
}

/// Replace or remove import
function handleImport(data) {
  if (data.includes('import Sugar')) {
    return data.replace('import Cartography', '')
  } else {
    return data.replace('import Cartography', 'import Sugar')
  }
}

/// Format transform to have `,` and linebreaks
function handleTransforms(transforms, indentationLength) {
  let string = ''

  // Expect the first is always line break
  if (transforms[0] != '\n') {
    transforms.unshift('\n')
  }

  const indentation = makeIndentation(indentationLength + 2)
  
  transforms.forEach((line, index) => {
    if (line.length > 1) {
      string = string.concat(indentation + line)
      if (index < transforms.length - 1) {
        string = string.concat(',\n')
      }
    } else {
      string = string.concat('\n')
    }    
  })

  return string
}

/// Replace superView
function handleSuperview(transforms, match) {
  const superView = findSuperview(match)
  if (superView == null) {
    return transforms
  }

  const superViewPattern = ' superView.'
  transforms = transforms.map((transform) => {
    if (transform.includes(superViewPattern)) {
      return transform.replace(superViewPattern, ` ${superView}.`)
    } else {
      return transform
    }
  })

  return transforms
}

/// Embed the statements inside `Constraint.on`
function handleStatements(statements, indentationLength) {
  const indentation = makeIndentation(indentationLength)
  return `${indentation}Constraint.on(${statements}\n${indentation})`
}

/// Turn every line into flatten transformed line
function handleMatch(match) {
  let lines = match.split('\n')
  lines = lines.map((line) => {
    return handleLine(line.trim())
  }).filter((transforms) => {
    return transforms != null
  })

  let flatten = [].concat.apply([], lines)
  return flatten
}

/// Check to handle lines, turn them into `LayoutAnchor` statements
function handleLine(line) {
  const itemPattern = '\\w*\\.\\w* (=|<|>)= \\w*\\.*\\..*'
  const sizePattern = '\w*\.\w* (=|<|>)= (\s|.)*'

  if (line.includes('edges == ')) {
    return handleEdges(line)
  } else if (line.includes('center == ')) {
    return handleCenter(line) 
  } else if (hasPattern(line, itemPattern) && !hasSizeKeywords(line)) {
    return handleItem(line)
  } else if (hasPattern(line, sizePattern)) {
    return handleSize(line)
  } else if (line.includes('>=') || line.includes('>=')) {
    return line
  } else if (line.includes('.')) {
    // return the line itself to let the human fix
    return line
  } else if (line.length == 0) {
    return ['\n']
  } else {
    return null
  }
}

/// For ex: listView.bottom == listView.superview!.bottom - 43
/// listView.bottom == listView.superview!.bottom - Metrics.BackButtonWidth
function handleItem(line) {
  const equalSign = getEqualSign(line)
  const parts = line.split(` ${equalSign} `)
  const left = parts[0]

  let rightParts = parts[1].trim()
  let right = rightParts.split(' ')[0]
  let number = rightParts.replace(right, '').replace('- ', '-').trim()
  if (number.startsWith('+ ')) {
    number = number.slice(2)
  }
  
  let equal = getEqual(line)

  if (number == null || number.length == 0 ) {
    return [
      `${left}Anchor.constraint(${equal}: ${right}Anchor)`
    ]
  } else {
    return [
      `${left}Anchor.constraint(${equal}: ${right}Anchor, constant: ${number})`
    ]
  }
}

/// For ex: segmentedControl.height == 24
/// backButton.width == Metrics.BackButtonWidth
function handleSize(line) {
  const equalSign = getEqualSign(line)
  const parts = line.split(` ${equalSign} `)
  const left = parts[0]
  const right = parts[1]

  let equal = getEqual(line)

  return [
    `${left}Anchor.constraint(${equal}Constant: ${right})`
  ]
}

/// For ex: mapView.edges == listView.edges
function handleEdges(line) {
  const parts = line.split(' == ')
  const left = parts[0].split('.')[0]
  const right = removeLastPart(parts[1])

  return [
    `${left}.topAnchor.constraint(equalTo: ${right}.topAnchor)`,
    `${left}.bottomAnchor.constraint(equalTo: ${right}.bottomAnchor)`,
    `${left}.leftAnchor.constraint(equalTo: ${right}.leftAnchor)`,
    `${left}.rightAnchor.constraint(equalTo: ${right}.rightAnchor)`
  ]
}

/// For ex: mapView.center == listView.center
function handleCenter(line) {
  const parts = line.split(' == ')
  const left = parts[0].split('.')[0]
  const right = removeLastPart(parts[1])
  
  return [
    `${left}.centerXAnchor.constraint(equalTo: ${right}.centerXAnchor)`,
    `${left}.centerYAnchor.constraint(equalTo: ${right}.centerYAnchor)`
  ]
}

function hasPattern(string, pattern) {
  const regex = new RegExp(pattern)
  let matches = string.match(regex)

  if (matches == null) {
    return false
  }

  matches = matches.filter((match) => {
    return match !== undefined && match.length > 1
  })

  return matches.length > 0
}

function removeLastPart(string) {
  const parts = string.split('.')
  parts.pop()

  return parts.join('.')
}

function getEqual(line) {
  if (line.includes('==')) {
    return 'equalTo'
  } else if (line.includes('<=')) {
    return 'lessThanOrEqualTo'
  } else {
    return 'greaterThanOrEqualTo'
  }
}

function getEqualSign(line) {
  if (line.includes('==')) {
    return '=='
  } else if (line.includes('>=')) {
    return '>='
  } else {
    return '<='
  }
}

function findIndentationLength(match) {
  return match.split('constrain')[0].length + 1
}

function makeIndentation(length) {
  return Array(length).join(' ')
}

// For ex: constrain(tableView, headerView, view) { tableView, headerView, superView in
function findSuperview(match) {
  const line = match.split('\n')[0]
  if (!line.includes(' superView in')) {
    return null
  }

  const pattern = ', \\w*\\)'
  const regex = RegExp(pattern)
  const string =  line.match(regex)[0] // `, view)

  return string.replace(', ', '').replace(')', '')
}

// Check special size keywords
function hasSizeKeywords(line) {
  const keywords = ['Metrics', 'Dimensions', 'UIScreen']
  return keywords.filter((keyword) => {
    return line.includes(keyword)
  }).length != 0
}

main()